feat: der letzte Auftrag - Vollständiges Deployment
- Frontend: HTML/CSS/JS Spielapp (1642 Zeilen) - Backend: Node.js/Express + SQLite server - REST API: game, spieler, notes - AI_INSTRUCTIONS.md documentation
This commit is contained in:
+204
-257
@@ -1256,165 +1256,147 @@
|
||||
</div><!-- /app -->
|
||||
|
||||
<script>
|
||||
// ══════════════════════════════════════════
|
||||
// DATA
|
||||
// ══════════════════════════════════════════
|
||||
const quests = [
|
||||
{
|
||||
id: 0,
|
||||
badge: 'easy',
|
||||
badgeLabel: 'EINSTEIGER',
|
||||
name: 'Das Lied der Liebe',
|
||||
holder: 'Hüter des <strong>Briefes</strong>',
|
||||
item: '💌 Brief des ersten Dates',
|
||||
desc: 'Der Held muss der Runde einen Liebesfilm-Titel nennen und ihn pantomimisch darstellen – die anderen raten.',
|
||||
success: '✓ Brief bekommt er',
|
||||
fail: '✗ Ein Schluck',
|
||||
details: `<strong>Ablauf:</strong> Der Hüter flüstert dem Held einen Filmtitel ins Ohr (z.B. Titanic, Die Schöne und das Biest, Notting Hill). Der Held darf nicht sprechen und muss den Titel pantomimisch darstellen. Zeitlimit: 60 Sekunden.`,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
badge: 'easy',
|
||||
badgeLabel: 'EINSTEIGER',
|
||||
name: 'Der Wissenstest',
|
||||
holder: 'Hüter des <strong>Rings</strong>',
|
||||
item: '💍 Ring der Verbindung',
|
||||
desc: 'Der Hüter stellt 5 Fragen über die Braut. Der Held muss mindestens 3 richtig beantworten.',
|
||||
success: '✓ Ring bekommt er',
|
||||
fail: '✗ Pro Falschantwort ein Schluck',
|
||||
details: `<strong>Fragen vorbereiten (Hüter):</strong> Wähle 5 aus diesen Kategorien:<br>
|
||||
• Lieblingsessen? • Lieblingsfilm? • Wo wollte sie immer hinreisen?<br>
|
||||
• Was ist ihr größter Tick? • Wie heißt ihre beste Freundin?<br>
|
||||
• Was war ihr erstes Haustier? • Was mag sie am Bräutigam am meisten?<br><br>
|
||||
<strong>Hinweis:</strong> Fragen vorab mit der Braut abstimmen.`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
badge: 'easy',
|
||||
badgeLabel: 'EINSTEIGER',
|
||||
name: 'Das Duell der Münzen',
|
||||
holder: 'Hüter des <strong>Schlüssels</strong>',
|
||||
item: '🔑 Schlüssel zur Zukunft',
|
||||
desc: 'Münze oder Kronkorken werfen: Wer 3 von 5 Würfen in einen Becher trifft, gewinnt.',
|
||||
success: '✓ Schlüssel bekommt er',
|
||||
fail: '✗ Ein ordentlicher Schluck',
|
||||
details: `<strong>Setup:</strong> Einen Becher auf den Tisch stellen, ca. 1–2 Meter Abstand. Jeder wirft abwechselnd (Held vs. Hüter). Bester aus 5 Versuchen gewinnt.<br><br>
|
||||
<strong>Regelung bei Gleichstand:</strong> Plötzlicher Tod – wer als nächstes trifft, gewinnt.`,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
badge: 'easy',
|
||||
badgeLabel: 'EINSTEIGER',
|
||||
name: 'Der Schwur der tausend Worte',
|
||||
holder: 'Hüter der <strong>Blume</strong>',
|
||||
item: '🌹 Blume der Ewigkeit',
|
||||
desc: 'Der Held muss in 90 Sekunden einen Heiratsantrag improvisieren – vor allen Gästen.',
|
||||
success: '✓ Blume bekommt er',
|
||||
fail: '✗ Kein Applaus = trinken',
|
||||
details: `<strong>Ablauf:</strong> Der Held hat 90 Sekunden Zeit für einen improvisierten Heiratsantrag an die (nicht anwesende) Braut. Die Gäste klatschen am Ende.<br><br>
|
||||
<strong>Bewertung:</strong> Lautester Applaus = Erfolg. Stille oder Widerspruch = Misserfolg. Der Hüter entscheidet im Zweifelsfall.`,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
badge: 'medium',
|
||||
badgeLabel: 'MITTEL',
|
||||
name: 'Die Chronik der Scham',
|
||||
holder: 'Hüter des <strong>Fotos</strong>',
|
||||
item: '📸 Foto der gemeinsamen Zeit',
|
||||
desc: 'Der Hüter erzählt eine peinliche Geschichte des Helden. Wer davon die meisten Details wusste, gewinnt.',
|
||||
success: '✓ Foto und eine Geschichte über die Braut zurück',
|
||||
fail: '✗ Der Held trinkt und erzählt selbst die Geschichte',
|
||||
details: `<strong>Ablauf:</strong> Der Hüter hat vorab eine peinliche Geschichte über den Helden vorbereitet. Er liest sie vor und stellt danach 3 Detailfragen. Kann der Held 2 von 3 beantworten? → Erfolg.<br><br>
|
||||
<strong>Empfehlung:</strong> Eine Person, die den Helden gut kennt, übernimmt diese Rolle.`,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
badge: 'medium',
|
||||
badgeLabel: 'MITTEL',
|
||||
name: 'Das Blindverkostung',
|
||||
holder: 'Hüter des <strong>Schuhs</strong>',
|
||||
item: '👠 Schuh der Flucht',
|
||||
desc: '5 Getränke blind verkosten und benennen. Mindestens 3 richtig.',
|
||||
success: '✓ Schuh bekommt er',
|
||||
fail: '✗ Alle falsch erkannten Getränke austrinken',
|
||||
details: `<strong>Vorbereitung:</strong> 5 Getränke in nummerierten Bechern vorbereiten (Mischung aus Alkohol und Softdrinks). Augenbinde aufsetzen.<br><br>
|
||||
<strong>Regeln:</strong> Riechen ist erlaubt. Geraten werden darf nur auf Basis der Verkostung.`,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
badge: 'hard',
|
||||
badgeLabel: 'HERAUSFORDERUNG',
|
||||
name: 'Der Zweikampf der Weisheit',
|
||||
holder: 'Hüter des <strong>Maskottchens</strong>',
|
||||
item: '🧸 Maskottchen des Heils',
|
||||
desc: 'Ein Trivia-Duell: Pop-Kultur, Warhammer & DnD-Fragen. Wer 3 von 5 beantwortet, gewinnt.',
|
||||
success: '✓ Maskottchen und ein Ehrentitel',
|
||||
fail: '✗ Zwei große Schlucke',
|
||||
details: `<strong>Beispielfragen:</strong><br>
|
||||
• RPG: „Wie viele Seiten hat ein W20?" (20)<br>
|
||||
• Warhammer: „Welche Fraktion trägt Schädelornamente als Zeichen der Loyalität?" (Space Marines / diverse)<br>
|
||||
• DnD: „Was ist ein nat. 20?" (Kritischer Erfolg)<br>
|
||||
• Allgemein: „Wer schrieb 'Der Herr der Ringe'?" (Tolkien)<br>
|
||||
• Persönlich: Eine Frage über das Paar.<br><br>
|
||||
<strong>Hinweis:</strong> Schwierigkeitsgrad der Fragen an die Gruppe anpassen.`,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
badge: 'hard',
|
||||
badgeLabel: 'HERAUSFORDERUNG',
|
||||
name: 'Die letzte Prüfung',
|
||||
holder: 'Hüter der <strong>Urkunde</strong>',
|
||||
item: '🏆 Urkunde der Heldentaten',
|
||||
desc: 'Der Held muss einen Abend-Rückblick halten: Die 5 peinlichsten Momente des Abends, dramatisch vorgetragen.',
|
||||
success: '✓ Urkunde + Applaus der Legende',
|
||||
fail: '✗ Nochmal von vorne – lauter!',
|
||||
details: `<strong>Ablauf:</strong> Diese Quest steht am Ende des Abends. Der Held fasst in einer Rede (2–3 Min.) die Ereignisse des Abends zusammen – pointiert und feierlich vorgetragen.<br><br>
|
||||
<strong>Bewertung:</strong> Stehende Ovationen = Erfolg. Unzureichende Darbietung = Neuversuch.<br><br>
|
||||
<strong>Abschluss:</strong> Nach bestandener Quest wird die Urkunde feierlich überreicht.`,
|
||||
},
|
||||
];
|
||||
// ═══════════════════════════════════════════
|
||||
// PART 1: API-HELPERS & STATE-SYNC
|
||||
// ═══════════════════════════════════════════
|
||||
const API = '/api';
|
||||
|
||||
async function apiGet(url) {
|
||||
try {
|
||||
const r = await fetch(API + url);
|
||||
return r.ok ? r.json() : null;
|
||||
} catch(e) { console.error('GET failed:', e); return null; }
|
||||
}
|
||||
|
||||
async function apiPost(url, body) {
|
||||
try {
|
||||
const r = await fetch(API + url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body || {})
|
||||
});
|
||||
return r.ok ? r.json() : null;
|
||||
} catch(e) { console.error('POST failed:', e); return null; }
|
||||
}
|
||||
|
||||
async function apiPut(url, body) {
|
||||
try {
|
||||
const r = await fetch(API + url, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body || {})
|
||||
});
|
||||
return r.ok ? r.json() : null;
|
||||
} catch(e) { console.error('PUT failed:', e); return null; }
|
||||
}
|
||||
|
||||
async function apiDelete(url) {
|
||||
try {
|
||||
const r = await fetch(API + url, {method: 'DELETE'});
|
||||
return r.ok ? r.json() : null;
|
||||
} catch(e) { console.error('DELETE failed:', e); return null; }
|
||||
}
|
||||
|
||||
// Lokale State-Variablen
|
||||
const itemCollected = new Array(8).fill(false);
|
||||
let drinks = 0;
|
||||
const questDone = new Array(quests.length).fill(false);
|
||||
const questDone = new Array(8).fill(false);
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// STARS
|
||||
// ══════════════════════════════════════════
|
||||
// State vom Server laden
|
||||
async function syncState() {
|
||||
const data = await apiGet('/game');
|
||||
if (!data) return;
|
||||
const s = data.state;
|
||||
if (s.itemCollected) s.itemCollected.forEach((v, i) => { itemCollected[i] = v; });
|
||||
if (s.drinks !== undefined) drinks = s.drinks;
|
||||
if (s.questDone) s.questDone.forEach((v, i) => { questDone[i] = v; });
|
||||
document.getElementById('drinkCount').textContent = drinks;
|
||||
}
|
||||
|
||||
// State zum Server speichern
|
||||
async function saveStateToServer(state) {
|
||||
await apiPost('/game/save-state', { ...state, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
// Lokale UI aktualisieren
|
||||
function refreshUI() {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const slot = document.getElementById('inv-' + i);
|
||||
if (slot) slot.classList.toggle('collected', itemCollected[i]);
|
||||
}
|
||||
const dc = document.getElementById('drinkCount');
|
||||
if (dc) dc.textContent = drinks;
|
||||
updateQuestStatus();
|
||||
renderSpieler();
|
||||
}
|
||||
|
||||
// Sync + Refresh
|
||||
async function updateAndSync(state) {
|
||||
await saveStateToServer(state);
|
||||
refreshUI();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// PART 2: QUEST-DATEN
|
||||
// ═══════════════════════════════════════════
|
||||
const quests = [
|
||||
{ id:0, badge:'easy', badgeLabel:'EINSTEIGER', name:'Das Lied der Liebe',
|
||||
holder:'Hüter des <strong>Briefes</strong>', item:'💌 Brief des ersten Dates',
|
||||
desc:'Der Held muss der Runde einen Liebesfilm-Titel nennen und ihn pantomimisch darstellen.',
|
||||
success:'✓ Brief bekommt er', fail:'✗ Ein Schluck',
|
||||
details:'Der Hüter flüstert dem Held einen Filmtitel ins Ohr. Der Held stellt ihn pantomimisch dar (60s).' },
|
||||
{ id:1, badge:'easy', badgeLabel:'EINSTEIGER', name:'Der Wissenstest',
|
||||
holder:'Hüter des <strong>Rings</strong>', item:'💍 Ring der Verbindung',
|
||||
desc:'Der Hüter stellt 5 Fragen über die Braut. Mindestens 3 richtig = Gewinn.',
|
||||
success:'✓ Ring bekommt er', fail:'✗ Pro Falschantwort ein Schluck',
|
||||
details:'Fragen: Lieblingsessen, Lieblingsfilm, Reisetraum, größter Tick, beste Freundin.' },
|
||||
{ id:2, badge:'easy', badgeLabel:'EINSTEIGER', name:'Das Duell der Münzen',
|
||||
holder:'Hüter des <strong>Schlüssels</strong>', item:'🔑 Schlüssel zur Zukunft',
|
||||
desc:'Münze oder Kronkorken werfen: Wer 3 von 5 in einen Becher trifft, gewinnt.',
|
||||
success:'✓ Schlüssel bekommt er', fail:'✗ Ein ordentlicher Schluck',
|
||||
details:'Becher 1-2m entfernt. Bester aus 5 Versuchen. Bei Gleichstand: plötzlicher Tod.' },
|
||||
{ id:3, badge:'easy', badgeLabel:'EINSTEIGER', name:'Der Schwur der tausend Worte',
|
||||
holder:'Hüter der <strong>Blume</strong>', item:'🌹 Blume der Ewigkeit',
|
||||
desc:'Der Held muss in 90 Sekunden einen Heiratsantrag improvisieren – vor allen Gästen.',
|
||||
success:'✓ Blume bekommt er', fail:'✗ Kein Applaus = trinken',
|
||||
details:'90s improvisierter Heiratsantrag. Lautester Applaus = Erfolg.' },
|
||||
{ id:4, badge:'medium', badgeLabel:'MITTEL', name:'Die Chronik der Scham',
|
||||
holder:'Hüter des <strong>Fotos</strong>', item:'📸 Foto der gemeinsamen Zeit',
|
||||
desc:'Der Hüter erzählt eine peinliche Geschichte. Wer die meisten Details wusste, gewinnt.',
|
||||
success:'✓ Foto und Geschichte über die Braut', fail:'✗ Held trinkt & erzählt selbst',
|
||||
details:'Hüter liest peinliche Geschichte. 3 Detailfragen. 2/3 richtig = Erfolg.' },
|
||||
{ id:5, badge:'medium', badgeLabel:'MITTEL', name:'Das Blindverkostung',
|
||||
holder:'Hüter des <strong>Schuhs</strong>', item:'👠 Schuh der Flucht',
|
||||
desc:'5 Getränke blind verkosten und benennen. Mindestens 3 richtig.',
|
||||
success:'✓ Schuh bekommt er', fail:'✗ Alle falsch erkannten austrinken',
|
||||
details:'5 nummerierte Becher (Alkohol+Softdrinks). Augenbinde auf. Riechen erlaubt.' },
|
||||
{ id:6, badge:'hard', badgeLabel:'HERAUSFORDERUNG', name:'Der Zweikampf der Weisheit',
|
||||
holder:'Hüter des <strong>Maskottchens</strong>', item:'🧸 Maskottchen des Heils',
|
||||
desc:'Trivia-Duell: Pop-Kultur, Warhammer & DnD. Wer 3 von 5 beantwortet, gewinnt.',
|
||||
success:'✓ Maskottchen und Ehrentitel', fail:'✗ Zwei große Schlucke',
|
||||
details:'RPG/Warhammer/DnD/Filmfragen. Schwierigkeitsgrad anpassen.' },
|
||||
{ id:7, badge:'hard', badgeLabel:'HERAUSFORDERUNG', name:'Die letzte Prüfung',
|
||||
holder:'Hüter der <strong>Urkunde</strong>', item:'🏆 Urkunde der Heldentaten',
|
||||
desc:'Abend-Rückblick: Die 5 peinlichsten Momente, dramatisch vorgetragen.',
|
||||
success:'✓ Urkunde + Applaus der Legende', fail:'✗ Nochmal von vorne – lauter!',
|
||||
details:'2-3 Min Rede. Stehende Ovationen = Erfolg.' },
|
||||
];
|
||||
// ═══════════════════════════════════════════
|
||||
// PART 3: STARS, TABS & START
|
||||
// ═══════════════════════════════════════════
|
||||
(function createStars() {
|
||||
const c = document.getElementById('stars');
|
||||
if (!c) return;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'star';
|
||||
s.style.cssText = `
|
||||
left:${Math.random()*100}%;
|
||||
top:${Math.random()*100}%;
|
||||
--d:${2+Math.random()*4}s;
|
||||
--delay:${Math.random()*5}s;
|
||||
--bright:${0.4+Math.random()*0.6};
|
||||
width:${Math.random()<0.3?3:2}px;
|
||||
height:${Math.random()<0.3?3:2}px;
|
||||
`;
|
||||
s.style.cssText = `left:${Math.random()*100}%;top:${Math.random()*100}%;
|
||||
--d:${2+Math.random()*4}s;--delay:${Math.random()*5}s;
|
||||
--bright:${0.4+Math.random()*0.6};width:${Math.random()<0.3?3:2}px;height:${Math.random()<0.3?3:2}px;`;
|
||||
c.appendChild(s);
|
||||
}
|
||||
})();
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// START
|
||||
// ══════════════════════════════════════════
|
||||
function startApp() {
|
||||
document.getElementById('cover').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'block';
|
||||
buildQuests();
|
||||
buildDice();
|
||||
loadState();
|
||||
loadSpieler();
|
||||
updateQuestStatus();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// TABS
|
||||
// ══════════════════════════════════════════
|
||||
function showTab(id) {
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||
@@ -1422,11 +1404,22 @@ function showTab(id) {
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// QUESTS
|
||||
// ══════════════════════════════════════════
|
||||
function startApp() {
|
||||
document.getElementById('cover').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'block';
|
||||
buildQuests();
|
||||
buildDice();
|
||||
syncState();
|
||||
syncSpieler();
|
||||
updateQuestStatus();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// PART 4: QUEST-FUNKTIONEN
|
||||
// ═══════════════════════════════════════════
|
||||
function buildQuests() {
|
||||
const grid = document.getElementById('questGrid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
quests.forEach((q, i) => {
|
||||
const card = document.createElement('div');
|
||||
@@ -1443,12 +1436,12 @@ function buildQuests() {
|
||||
</div>
|
||||
<div class="quest-expand">${q.details}<br><br>
|
||||
<button onclick="event.stopPropagation(); toggleDone(${i})"
|
||||
style="font-family:'Cinzel',serif; font-size:0.75rem; background:transparent; border:1px solid #8a6520; padding:0.3rem 0.8rem; cursor:pointer; color:#8a6520; border-radius:3px;">
|
||||
style="font-family:'Cinzel',serif;font-size:0.75rem;background:transparent;border:1px solid #8a6520;
|
||||
padding:0.3rem 0.8rem;cursor:pointer;color:#8a6520;border-radius:3px;">
|
||||
${questDone[i] ? '↩ Als offen markieren' : '✓ Als erledigt markieren'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="expand-hint">▼ Aufklappen für Details</div>
|
||||
`;
|
||||
<div class="expand-hint">▼ Aufklappen für Details</div>`;
|
||||
card.addEventListener('click', () => toggleExpand(i));
|
||||
grid.appendChild(card);
|
||||
});
|
||||
@@ -1456,57 +1449,61 @@ function buildQuests() {
|
||||
|
||||
function toggleExpand(i) {
|
||||
const card = document.getElementById('qcard-' + i);
|
||||
card.classList.toggle('expanded');
|
||||
if (card) card.classList.toggle('expanded');
|
||||
}
|
||||
|
||||
function toggleDone(i) {
|
||||
async function toggleDone(i) {
|
||||
questDone[i] = !questDone[i];
|
||||
saveState();
|
||||
const sv = await apiGet('/game');
|
||||
if (!sv) return;
|
||||
const s = {...sv.state, questDone: [...questDone]};
|
||||
await updateAndSync(s);
|
||||
buildQuests();
|
||||
updateQuestStatus();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// INVENTORY
|
||||
// ══════════════════════════════════════════
|
||||
function toggleItem(i) {
|
||||
// ═══════════════════════════════════════════
|
||||
// PART 5: INVENTORY & DRINKS
|
||||
// ═══════════════════════════════════════════
|
||||
async function toggleItem(i) {
|
||||
itemCollected[i] = !itemCollected[i];
|
||||
const slot = document.getElementById('inv-' + i);
|
||||
slot.classList.toggle('collected', itemCollected[i]);
|
||||
saveState();
|
||||
if (slot) slot.classList.toggle('collected', itemCollected[i]);
|
||||
const sv = await apiGet('/game');
|
||||
if (!sv) return;
|
||||
const s = {...sv.state, itemCollected: [...itemCollected]};
|
||||
await updateAndSync(s);
|
||||
updateQuestStatus();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// DRINKS
|
||||
// ══════════════════════════════════════════
|
||||
function changeDrinks(n) {
|
||||
drinks = Math.max(0, drinks + n);
|
||||
async function changeDrinks(n) {
|
||||
const res = await apiPost('/game/drink', {amount: n});
|
||||
if (res && res.drinks !== undefined) {
|
||||
drinks = res.drinks;
|
||||
document.getElementById('drinkCount').textContent = drinks;
|
||||
saveState();
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// STATUS
|
||||
// ══════════════════════════════════════════
|
||||
// ═══════════════════════════════════════════
|
||||
// PART 6: STATUS
|
||||
// ═══════════════════════════════════════════
|
||||
function updateQuestStatus() {
|
||||
const count = itemCollected.filter(Boolean).length;
|
||||
document.getElementById('questCount').textContent = count + '/8';
|
||||
|
||||
const qc = document.getElementById('questCount');
|
||||
if (qc) qc.textContent = count + '/8';
|
||||
const list = document.getElementById('questStatusList');
|
||||
if (!list) return;
|
||||
list.innerHTML = quests.map((q, i) => `
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; font-size:0.9rem; color:${questDone[i]?'#2a5a1a':'#5a3a10'};">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;
|
||||
color:${questDone[i]?'#2a5a1a':'#5a3a10'};">
|
||||
<span>${questDone[i] ? '✓' : '○'}</span>
|
||||
<span>${q.name}</span>
|
||||
<span style="margin-left:auto; font-size:0.8rem; opacity:0.7">${q.item}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
<span style="margin-left:auto;font-size:0.8rem;opacity:0.7">${q.item}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// DICE
|
||||
// ══════════════════════════════════════════
|
||||
// ═══════════════════════════════════════════
|
||||
// PART 7: WÜRFEL
|
||||
// ═══════════════════════════════════════════
|
||||
function buildDice() {
|
||||
const btn = document.getElementById('diceBtns');
|
||||
if (!btn) return;
|
||||
@@ -1523,6 +1520,7 @@ function rollDice(sides) {
|
||||
const result = Math.floor(Math.random() * sides) + 1;
|
||||
const el = document.getElementById('diceResult');
|
||||
const log = document.getElementById('diceLog');
|
||||
if (!el || !log) return;
|
||||
el.classList.remove('rolling');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('rolling');
|
||||
@@ -1533,10 +1531,9 @@ function rollDice(sides) {
|
||||
result >= Math.ceil(sides * 0.4) ? '~ Mittelmaß...' : '✗ Schwach. Trinken?';
|
||||
log.textContent = `W${sides} → ${result} · ${reactions}`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// SPIELER
|
||||
// ══════════════════════════════════════════
|
||||
// ═══════════════════════════════════════════
|
||||
// PART 8: SPIELER (API-Version)
|
||||
// ═══════════════════════════════════════════
|
||||
let spieler = [];
|
||||
|
||||
const rolleLabels = {
|
||||
@@ -1548,6 +1545,13 @@ const rolleLabels = {
|
||||
gast: '🍺 Gast',
|
||||
};
|
||||
|
||||
async function syncSpieler() {
|
||||
const data = await apiGet('/spieler');
|
||||
if (data) spieler = data;
|
||||
populateQuestSelect();
|
||||
renderSpieler();
|
||||
}
|
||||
|
||||
function populateQuestSelect() {
|
||||
const sel = document.getElementById('sQuest');
|
||||
if (!sel) return;
|
||||
@@ -1562,7 +1566,7 @@ function populateQuestSelect() {
|
||||
sel.value = current;
|
||||
}
|
||||
|
||||
function addSpieler() {
|
||||
async function addSpieler() {
|
||||
const nameEl = document.getElementById('sName');
|
||||
const rolleEl = document.getElementById('sRolle');
|
||||
const questEl = document.getElementById('sQuest');
|
||||
@@ -1570,32 +1574,32 @@ function addSpieler() {
|
||||
if (!name) { nameEl.focus(); return; }
|
||||
const rolle = rolleEl.value;
|
||||
const questIdx = questEl.value !== '' ? parseInt(questEl.value) : null;
|
||||
spieler.push({ id: Date.now(), name, rolle, questIdx });
|
||||
const res = await apiPost('/spieler', {name, rolle, quest_id: questIdx});
|
||||
if (res && res.spieler) {
|
||||
spieler.push(res.spieler);
|
||||
nameEl.value = '';
|
||||
questEl.value = '';
|
||||
saveSpieler();
|
||||
renderSpieler();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSpieler(id) {
|
||||
async function deleteSpieler(id) {
|
||||
await apiDelete('/spieler/' + id);
|
||||
spieler = spieler.filter(s => s.id !== id);
|
||||
saveSpieler();
|
||||
renderSpieler();
|
||||
}
|
||||
|
||||
function changeRolle(id, rolle) {
|
||||
async function changeRolle(id, rolle) {
|
||||
const s = spieler.find(s => s.id === id);
|
||||
if (s) { s.rolle = rolle; saveSpieler(); renderSpieler(); }
|
||||
if (s) { s.rolle = rolle; await apiPut('/spieler/' + id, {rolle}); renderSpieler(); }
|
||||
}
|
||||
|
||||
function changeQuest(id, val) {
|
||||
async function changeQuest(id, val) {
|
||||
const s = spieler.find(s => s.id === id);
|
||||
if (s) { s.questIdx = val !== '' ? parseInt(val) : null; saveSpieler(); renderSpieler(); }
|
||||
if (s) { s.questIdx = val !== '' ? parseInt(val) : null; await apiPut('/spieler/' + id, {quest_id: s.questIdx}); renderSpieler(); }
|
||||
}
|
||||
|
||||
function getInitial(name) {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
function getInitial(name) { return name.charAt(0).toUpperCase(); }
|
||||
|
||||
function renderSpieler() {
|
||||
const list = document.getElementById('spielerList');
|
||||
@@ -1605,8 +1609,7 @@ function renderSpieler() {
|
||||
} else {
|
||||
list.innerHTML = spieler.map(s => {
|
||||
const questName = s.questIdx !== null ? quests[s.questIdx]?.name : null;
|
||||
return `
|
||||
<div class="spieler-item">
|
||||
return `<div class="spieler-item">
|
||||
<div class="spieler-avatar">${getInitial(s.name)}</div>
|
||||
<div class="spieler-info">
|
||||
<div class="spieler-name-display">${s.name}</div>
|
||||
@@ -1617,15 +1620,13 @@ function renderSpieler() {
|
||||
<option value="">– Keine Quest –</option>
|
||||
${quests.map((q,i) => `<option value="${i}" ${s.questIdx===i?'selected':''}>${q.name}</option>`).join('')}
|
||||
</select>
|
||||
<select class="spieler-select" style="font-size:0.8rem; padding:0.25rem 0.4rem;" onchange="changeRolle(${s.id}, this.value)">
|
||||
<select class="spieler-select" style="font-size:0.8rem;padding:0.25rem 0.4rem;" onchange="changeRolle(${s.id}, this.value)">
|
||||
${Object.entries(rolleLabels).map(([k,v]) => `<option value="${k}" ${s.rolle===k?'selected':''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
<button class="btn-del" onclick="deleteSpieler(${s.id})">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Summary
|
||||
const summary = document.getElementById('spielerSummary');
|
||||
if (summary) {
|
||||
summary.style.display = spieler.length ? 'flex' : 'none';
|
||||
@@ -1636,60 +1637,6 @@ function renderSpieler() {
|
||||
}
|
||||
}
|
||||
|
||||
function saveSpieler() {
|
||||
try { localStorage.setItem('jga_spieler', JSON.stringify(spieler)); } catch(e) {}
|
||||
}
|
||||
|
||||
function loadSpieler() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem('jga_spieler') || 'null');
|
||||
if (s) spieler = s;
|
||||
} catch(e) {}
|
||||
populateQuestSelect();
|
||||
renderSpieler();
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
localStorage.setItem('jga_state', JSON.stringify({ itemCollected, drinks, questDone }));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem('jga_state') || 'null');
|
||||
if (!s) return;
|
||||
s.itemCollected?.forEach((v, i) => {
|
||||
itemCollected[i] = v;
|
||||
const slot = document.getElementById('inv-' + i);
|
||||
if (slot && v) slot.classList.add('collected');
|
||||
});
|
||||
drinks = s.drinks || 0;
|
||||
document.getElementById('drinkCount').textContent = drinks;
|
||||
s.questDone?.forEach((v, i) => { questDone[i] = v; });
|
||||
const notes = localStorage.getItem('jga_notes');
|
||||
if (notes) document.getElementById('notes').value = notes;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function saveNotes() {
|
||||
try { localStorage.setItem('jga_notes', document.getElementById('notes').value); } catch(e) {}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (!confirm('Wirklich alles zurücksetzen?')) return;
|
||||
itemCollected.fill(false);
|
||||
questDone.fill(false);
|
||||
drinks = 0;
|
||||
document.querySelectorAll('.item-slot').forEach(s => s.classList.remove('collected'));
|
||||
document.getElementById('drinkCount').textContent = 0;
|
||||
document.getElementById('notes').value = '';
|
||||
spieler = [];
|
||||
renderSpieler();
|
||||
try { localStorage.clear(); } catch(e) {}
|
||||
buildQuests();
|
||||
updateQuestStatus();
|
||||
}
|
||||
// Start bei Page Load
|
||||
document.addEventListener('DOMContentLoaded', startApp);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
const express = require('express');
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../')));
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'data.db');
|
||||
|
||||
function getDb() {
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY, value TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS spieler (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT, rolle TEXT, quest_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS drinks_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
amount INTEGER, note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
let db = getDb();
|
||||
|
||||
function initState() {
|
||||
const row = db.prepare("SELECT value FROM state WHERE key = 'game'").get();
|
||||
if (!row) {
|
||||
const s = {
|
||||
gameName: '', gameStarted: false, drinks: 0,
|
||||
items: [false,false,false,false,false,false,false,false],
|
||||
quests: [false,false,false,false,false,false,false,false],
|
||||
timestamp: Date.now()
|
||||
};
|
||||
db.prepare('INSERT OR REPLACE INTO state (key,value) VALUES (?,?)').run('game', JSON.stringify(s));
|
||||
}
|
||||
}
|
||||
initState();
|
||||
|
||||
// GET state
|
||||
app.get('/api/game', (req, res) => {
|
||||
initState();
|
||||
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
|
||||
res.json({
|
||||
state: JSON.parse(sv.value),
|
||||
spieler: db.prepare('SELECT * FROM spieler ORDER BY id').all(),
|
||||
notes: db.prepare('SELECT * FROM notes ORDER BY created_at DESC').all(),
|
||||
stats: db.prepare('SELECT SUM(amount) as total, COUNT(*) as entries FROM drinks_log').get() || {total:0,entries:0}
|
||||
});
|
||||
});
|
||||
|
||||
// Start
|
||||
app.post('/api/game/start', (req, res) => {
|
||||
initState();
|
||||
const {name} = req.body;
|
||||
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
|
||||
const s = JSON.parse(sv.value);
|
||||
s.gameName = name || 'Der letzte Auftrag';
|
||||
s.gameStarted = true; s.timestamp = Date.now();
|
||||
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
|
||||
res.json({success:true, state:s});
|
||||
});
|
||||
|
||||
// Drink
|
||||
app.post('/api/game/drink', (req, res) => {
|
||||
initState();
|
||||
const amt = req.body.amount || 1;
|
||||
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
|
||||
const s = JSON.parse(sv.value);
|
||||
s.drinks = Math.max(0, s.drinks + amt);
|
||||
s.timestamp = Date.now();
|
||||
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
|
||||
db.prepare('INSERT INTO drinks_log (amount) VALUES (?)').run(amt);
|
||||
res.json({success:true, drinks:s.drinks});
|
||||
});
|
||||
|
||||
// Reset drink
|
||||
app.post('/api/game/drink/reset', (req, res) => {
|
||||
initState();
|
||||
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
|
||||
const s = JSON.parse(sv.value);
|
||||
s.drinks = 0; s.timestamp = Date.now();
|
||||
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
|
||||
res.json({success:true, drinks:0});
|
||||
});
|
||||
|
||||
// Toggle item
|
||||
app.post('/api/game/toggle-item', (req, res) => {
|
||||
initState();
|
||||
const idx = parseInt(req.body.index);
|
||||
if (idx<0||idx>7) return res.status(400).json({error:'Index 0-7'});
|
||||
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
|
||||
const s = JSON.parse(sv.value);
|
||||
s.items[idx] = !s.items[idx];
|
||||
s.timestamp = Date.now();
|
||||
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
|
||||
res.json({success:true, state:s, collected:s.items.filter(Boolean).length});
|
||||
});
|
||||
|
||||
// Toggle quest
|
||||
app.post('/api/game/toggle-quest', (req, res) => {
|
||||
initState();
|
||||
const idx = parseInt(req.body.index);
|
||||
if (idx<0||idx>7) return res.status(400).json({error:'Index 0-7'});
|
||||
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
|
||||
const s = JSON.parse(sv.value);
|
||||
s.quests[idx] = !s.quests[idx];
|
||||
s.timestamp = Date.now();
|
||||
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
|
||||
res.json({success:true, state:s});
|
||||
});
|
||||
|
||||
// Save full state
|
||||
app.post('/api/game/save-state', (req, res) => {
|
||||
initState();
|
||||
const s = req.body; s.timestamp = Date.now();
|
||||
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
|
||||
res.json({success:true, state:s});
|
||||
});
|
||||
|
||||
// Reset all
|
||||
app.post('/api/game/reset', (req, res) => {
|
||||
initState();
|
||||
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
|
||||
const s = JSON.parse(sv.value);
|
||||
s.drinks=0; s.items=[false,false,false,false,false,false,false,false];
|
||||
s.quests=[false,false,false,false,false,false,false,false];
|
||||
s.gameStarted=false; s.gameName=''; s.timestamp=Date.now();
|
||||
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
|
||||
db.prepare('DELETE FROM notes').run();
|
||||
db.prepare('DELETE FROM drinks_log').run();
|
||||
res.json({success:true, state:s});
|
||||
});
|
||||
|
||||
// Spieler CRUD
|
||||
app.get('/api/spieler', (req, res) => res.json(db.prepare('SELECT * FROM spieler ORDER BY id').all()));
|
||||
app.post('/api/spieler', (req, res) => {
|
||||
const {name, rolle, quest_id} = req.body;
|
||||
const r = db.prepare('INSERT INTO spieler (name,rolle,quest_id) VALUES (?,?,?)').run(name, rolle, quest_id||null);
|
||||
res.json({success:true, spieler:db.prepare('SELECT * FROM spieler WHERE id=?').get(r.lastInsertRowid)});
|
||||
});
|
||||
app.put('/api/spieler/:id', (req, res) => {
|
||||
const {name, rolle, quest_id} = req.body;
|
||||
db.prepare('UPDATE spieler SET name=COALESCE(?,name),rolle=COALESCE(?,rolle),quest_id=COALESCE(?,quest_id) WHERE id=?')
|
||||
.run(name||null, rolle||null, quest_id||null, req.params.id);
|
||||
res.json({success:true, spieler:db.prepare('SELECT * FROM spieler WHERE id=?').get(req.params.id)});
|
||||
});
|
||||
app.delete('/api/spieler/:id', (req, res) => {
|
||||
db.prepare('DELETE FROM spieler WHERE id=?').run(req.params.id);
|
||||
res.json({success:true});
|
||||
});
|
||||
|
||||
// Notes
|
||||
app.post('/api/notes', (req, res) => {
|
||||
const {body} = req.body;
|
||||
const r = db.prepare('INSERT INTO notes (body) VALUES (?)').run(body);
|
||||
res.json({success:true, note:db.prepare('SELECT * FROM notes WHERE id=?').get(r.lastInsertRowid)});
|
||||
});
|
||||
|
||||
// Stats
|
||||
app.get('/api/game/stats', (req, res) => {
|
||||
initState();
|
||||
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
|
||||
const s = JSON.parse(sv.value);
|
||||
res.json({
|
||||
collected: s.items.filter(Boolean).length, total:8,
|
||||
questsDone: s.quests.filter(Boolean).length, questsTotal:8,
|
||||
drinks: s.drinks, gameName: s.gameName,
|
||||
spielerCount: db.prepare('SELECT COUNT(*) as c FROM spieler').get().c,
|
||||
spieler: db.prepare('SELECT * FROM spieler').all()
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 8082;
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🏰 Der letzte Auftrag server on port ${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user