Files
dickendock/frontend/src/tools/gebietseroberung.jsx

644 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
import { useState, useEffect, useCallback, useRef } from 'react';
import { api, S } from '../lib.js';
const GRID = 20;
const MY_COLOR = '#4ecdc4';
const OPP_COLOR = '#ff6b9d';
const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
// Terrain-Konstanten (müssen mit Backend übereinstimmen)
const T_NORMAL = 0;
const T_GOLD = 1;
const T_MINE = 2;
const T_ROCK = 3;
const T_FOG = -1; // maskiert vom Server
function getMyId() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).id || null; } catch { return null; }
}
function getMyRole() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role || null; } catch { return null; }
}
// ── Spielerklärung ────────────────────────────────────────────────────────────
function HelpModal({ onClose }) {
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.85)', zIndex:1000, display:'flex', flexDirection:'column' }}
onClick={onClose}>
<div style={{ ...S.card, maxWidth:460, width:'100%', margin:'16px auto', display:'flex', flexDirection:'column',
maxHeight:'calc(100dvh - 32px)', overflow:'hidden' }}
onClick={e => e.stopPropagation()}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
paddingBottom:14, marginBottom:14, borderBottom:'1px solid rgba(255,255,255,0.08)', flexShrink:0 }}>
<span style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:14, fontWeight:700, letterSpacing:1 }}> SPIELREGELN</span>
<button onClick={onClose} style={S.btn('#ff6b9d', true)}> Schließen</button>
</div>
<div style={{ overflowY:'auto', paddingBottom:'calc(56px + env(safe-area-inset-bottom, 0px))' }}>
<div style={{ color:'rgba(255,255,255,0.75)', fontSize:13, lineHeight:1.9, fontFamily:'monospace' }}>
<p style={{ marginTop:0 }}>
<strong style={{ color:MY_COLOR }}>Ziel:</strong> Sammle mehr <strong>Punkte</strong> als dein Gegner durch clevere Expansion und das Meiden von Fallen.
</p>
<p><strong style={{ color:'#fff' }}>Ablauf:</strong><br/>
Beide starten in gegenüberliegenden Ecken. Du hast einen <strong style={{ color:MY_COLOR }}>Kopf</strong> dein letzter Zug, markiert mit einem leuchtenden Rahmen. Du kannst <strong>nur an den Kopf anbauen</strong> (alle 8 Richtungen). Dein Gebiet bleibt erhalten, aber du kannst nicht mehr von alten Feldern aus expandieren. Tippe ein Feld an, bestätige fertig.
</p>
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'12px 14px', marginBottom:12, border:'1px solid rgba(255,255,255,0.08)' }}>
<div style={{ marginBottom:8, color:'#fff', fontSize:12, letterSpacing:1 }}>FELDER & PUNKTE</div>
<div style={{ display:'grid', gridTemplateColumns:'28px 1fr', gap:'6px 10px', alignItems:'center' }}>
<span style={{ fontSize:16, textAlign:'center' }}></span><span><strong style={{ color:'rgba(255,255,255,0.9)' }}>Normales Feld</strong> +1 Punkt</span>
<span style={{ fontSize:16, textAlign:'center' }}></span><span><strong style={{ color:'#ffe66d' }}>Goldfeld</strong> +3 Punkte, sehr wertvoll!</span>
<span style={{ fontSize:16, textAlign:'center' }}>💣</span><span><strong style={{ color:'#ff6b9d' }}>Sprengmine</strong> Betreten zündet eine Explosion: alle 8 umliegenden Felder werden zu Felsen (auch Gold!). Besetzte Felder gehen verloren. Liegt eine weitere Mine im Umkreis, explodiert auch diese (Kettenreaktion!). Du verlierst deinen nächsten Zug.</span>
<span style={{ fontSize:16, textAlign:'center' }}>🪨</span><span><strong style={{ color:'rgba(255,255,255,0.4)' }}>Felsen</strong> unbesetzbar, blockiert Wege</span>
</div>
</div>
<p>
<strong style={{ color:'#fff' }}>🌫 Nebel:</strong><br/>
Du siehst nur dein Gebiet und die direkt angrenzenden Felder. Goldfelder und Minen bleiben verborgen bis du nahe genug herankommst also aufgepasst!
</p>
<p>
<strong style={{ color:'#ffe66d' }}>🔭 Kundschaften:</strong><br/>
Statt ein Feld zu besetzen kannst du einmal pro Zug <strong>kundschaften</strong>: tippe auf den Button <strong style={{ color:'#ffe66d' }}>🔭 Kundschaften</strong>, dann wähle einen Mittelpunkt auf dem Spielfeld ein <strong>3×3 Bereich</strong> wird nur für dich aufgedeckt. Dein Gegner sieht nicht was du entdeckt hast. Kostet deinen Zug, gibt dir aber wertvolle Information über Minen und Gold.
</p>
<p>
<strong style={{ color:'#fff' }}>Spielende:</strong><br/>
Das Spiel endet wenn niemand mehr ziehen kann oder wenn jemand <strong>doppelt so viele Punkte</strong> hat wie der Gegner (Dominanzsieg 👑). Wer mehr Punkte hat, gewinnt.
</p>
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'10px 14px', border:'1px solid rgba(255,255,255,0.08)' }}>
📱 <em>Auf dem Handy: Pinch zum Zoomen, Feld antippen und bestätigen.</em>
</div>
</div>
</div>
</div>
</div>
);
}
// ── Neues Spiel Modal ─────────────────────────────────────────────────────────
function NewGameModal({ onClose, onCreated, toast }) {
const [users, setUsers] = useState([]);
const [oppId, setOppId] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => { api('/tools/gebietseroberung/users').then(setUsers).catch(() => {}); }, []);
const create = async () => {
if (!oppId) return;
setLoading(true);
try {
const res = await api('/tools/gebietseroberung', { body: { opponent_id: Number(oppId) } });
toast('Spiel erstellt! Dein Gegner wurde benachrichtigt ⬡');
onCreated(res.id);
} catch(e) {
toast(e.message || 'Fehler beim Erstellen', 'error');
} finally { setLoading(false); }
};
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.85)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:1000, padding:20 }}>
<div style={{ ...S.card, maxWidth:360, width:'100%' }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:18 }}>
<span style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:13, fontWeight:700, letterSpacing:1 }}>NEUES SPIEL</span>
<button onClick={onClose} style={S.btn('#666666', true)}></button>
</div>
<div style={{ ...S.head, marginBottom:6 }}>GEGNER WÄHLEN</div>
<select value={oppId} onChange={e => setOppId(e.target.value)} style={{ ...S.inp, marginBottom:18 }}>
<option value=''>-- Benutzer wählen --</option>
{users.map(u => <option key={u.id} value={u.id}>{u.username}</option>)}
</select>
<div style={{ display:'flex', gap:8 }}>
<button onClick={onClose} style={{ ...S.btn('#666666'), flex:1 }}>Abbrechen</button>
<button onClick={create} disabled={!oppId || loading} style={{ ...S.btn('#4ecdc4'), flex:1 }}>
{loading ? 'Erstelle…' : 'Spiel starten'}
</button>
</div>
</div>
</div>
);
}
// ── Topliste ──────────────────────────────────────────────────────────────────
function Leaderboard({ reloadKey }) {
const [rows, setRows] = useState(null);
useEffect(() => { api('/tools/gebietseroberung/leaderboard').then(setRows).catch(() => setRows([])); }, [reloadKey]);
if (!rows) return null;
const medals = ['🥇','🥈','🥉'];
return (
<div style={{ marginTop:28 }}>
<div style={{ ...S.head, marginBottom:10 }}>TOPLISTE SIEGE</div>
{rows.length === 0
? <div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12, textAlign:'center', padding:'16px 0' }}>Noch keine abgeschlossenen Spiele.</div>
: rows.map((r,i) => (
<div key={r.username} style={{ display:'flex', alignItems:'center', gap:12, padding:'10px 14px', marginBottom:6, background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:8 }}>
<span style={{ fontSize:16, width:24, textAlign:'center', flexShrink:0 }}>{medals[i] || `${i+1}.`}</span>
<span style={{ flex:1, color:'#fff', fontFamily:'monospace', fontSize:13 }}>{r.username}</span>
<span style={{ color:MY_COLOR, fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700 }}>{r.wins}</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>Siege</span>
</div>
))
}
</div>
);
}
// ── Spielfeld ─────────────────────────────────────────────────────────────────
function GameBoard({ game, myId, onMove, onScout, toast }) {
const [pending, setPending] = useState(null);
const [moving, setMoving] = useState(false);
const [scoutMode, setScoutMode] = useState(false); // Scout-Modus aktiv
const [scoutPend, setScoutPend] = useState(null); // gewählter Scout-Mittelpunkt
const [mineAlert, setMineAlert] = useState(null); // letztes Mine-Event
const grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]');
const hasTerrain = terrain.length > 0;
const isMyTurn = game.current_turn === myId;
const opponent = game.owner_id === myId ? game.opponent_id : game.owner_id;
const myColor = game.owner_id === myId ? MY_COLOR : OPP_COLOR;
const oppColor = game.owner_id === myId ? OPP_COLOR : MY_COLOR;
const oppName = game.owner_id === myId ? game.opp_name : game.owner_name;
// Mine-Event anzeigen wenn frisch
const [blastHighlight, setBlastHighlight] = useState(new Set());
const [alertShownAt, setAlertShownAt] = useState(null); // move_count beim Anzeigen
useEffect(() => {
if (!game.last_event) {
setMineAlert(null); // Event wurde vom Server geclearet (nächster Zug)
return;
}
try {
const ev = JSON.parse(game.last_event);
if (ev.type === 'mine') {
setMineAlert(ev);
setAlertShownAt(game.move_count);
if (ev.blastCells?.length) {
const keys = new Set(ev.blastCells.map(([r,c]) => `${r}-${c}`));
setBlastHighlight(keys);
setTimeout(() => setBlastHighlight(new Set()), 2000);
}
}
} catch {}
}, [game.last_event, game.move_count]);
// Schlangen-Köpfe aus Server-Response
const myHeadStr = game.myHead;
const myHead = myHeadStr ? myHeadStr.split(',').map(Number) : null;
const [myHR, myHC] = myHead ?? [null, null];
const oppHeadStr = game.oppHead;
const oppHead = oppHeadStr ? oppHeadStr.split(',').map(Number) : null;
const [oppHR, oppHC] = oppHead ?? [null, null];
const isAdjacentToHead = (r, c) => {
if (myHR === null) return false;
return Math.abs(r - myHR) <= 1 && Math.abs(c - myHC) <= 1 && !(r === myHR && c === myHC);
};
const canClick = (r, c) => {
if (scoutMode) return false; // im Scout-Modus kein normaler Zug
if (!isMyTurn || game.status !== 'active') return false;
if (grid[r][c] !== 0) return false;
if (hasTerrain && terrain[r][c] === T_ROCK) return false;
if (myHead) return isAdjacentToHead(r, c);
return DIRS8.some(([dr,dc]) => {
const nr=r+dr, nc=c+dc;
return nr>=0&&nr<GRID&&nc>=0&&nc<GRID&&grid[nr][nc]===myId;
});
};
const handleCellClick = (r, c) => {
if (!canClick(r, c) || moving) return;
if (pending?.row === r && pending?.col === c) { setPending(null); return; }
setPending({ row:r, col:c });
};
const confirmMove = async () => {
if (!pending || moving) return;
setMoving(true);
try {
await onMove(pending.row, pending.col);
setPending(null);
} catch(e) {
toast?.(e.message || 'Fehler', 'error');
} finally { setMoving(false); }
};
// Scores kommen vom Server (auf ungemasktem Grid berechnet — korrekt für beide Spieler)
const myScore = game.myScore ?? 0;
const oppScore = game.oppScore ?? 0;
const myCount = grid.flat().filter(v => v === myId).length;
const oppCount = grid.flat().filter(v => v === opponent).length;
const fogCount = grid.flat().filter(v => v === 0 && hasTerrain).length;
let banner = null;
if (game.status === 'finished') {
const isDominance = myScore >= oppScore * 2 || oppScore >= myScore * 2;
if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' };
else if (game.winner_id === myId) banner = { text: isDominance ? 'Dominanzsieg! 👑🎉' : 'Du hast gewonnen! 🎉', color:MY_COLOR };
else banner = { text: isDominance ? 'Dominanzniederlage 💀' : 'Du hast verloren.', color:OPP_COLOR };
}
const [cellSize, setCellSize] = useState(14);
useEffect(() => {
const calc = () => {
const vw = Math.min(window.innerWidth, 600);
const vh = window.visualViewport?.height ?? window.innerHeight;
// Overhead: Header(36) + Scoreboard(50) + Status(30) + Legende(26) + margins(28) = ~170px
const availH = vh - 170;
const byW = Math.floor((vw - 32) / GRID);
const byH = Math.floor(availH / GRID);
setCellSize(Math.max(10, Math.min(byW, byH)));
};
calc();
window.visualViewport?.addEventListener('resize', calc);
window.addEventListener('resize', calc);
return () => {
window.visualViewport?.removeEventListener('resize', calc);
window.removeEventListener('resize', calc);
};
}, []);
// Zell-Rendering
const handleScoutClick = (r, c) => {
if (!scoutPend) return;
setScoutPend({ row:r, col:c, confirmed: true });
};
const confirmScout = async () => {
if (!scoutPend || moving) return;
setMoving(true);
try {
await onScout(scoutPend.row, scoutPend.col);
setScoutMode(false);
setScoutPend(null);
} catch(e) {
toast?.(e.message || 'Fehler', 'error');
} finally { setMoving(false); }
};
const renderCell = (r, c) => {
const cell = grid[r][c];
const t = hasTerrain ? terrain[r][c] : T_NORMAL;
const clic = canClick(r, c);
const isPend = pending?.row === r && pending?.col === c;
const isBlast = blastHighlight.has(`${r}-${c}`);
const isHead = myHR === r && myHC === c;
const isOppHead = game.status === 'finished' && oppHR === r && oppHC === c;
// Scout-Preview: 3x3 um Maus-Position im Scout-Modus
const isScoutPreview = scoutPend &&
Math.abs(r - scoutPend.row) <= 1 && Math.abs(c - scoutPend.col) <= 1;
const isFog = t === T_FOG;
const isRock = t === T_ROCK;
const isGold = t === T_GOLD;
const isMine = t === T_MINE;
let bg = 'rgba(255,255,255,0.05)';
let content = null;
let border = '1px solid transparent';
if (isFog || (cell === 0 && !clic && hasTerrain && t === T_FOG)) {
bg = 'rgba(0,0,0,0.4)'; // dunkler Nebel
} else if (isRock) {
bg = 'rgba(255,255,255,0.12)';
content = cellSize > 16 ? '🪨' : null;
} else if (cell === myId) {
bg = isGold ? `#ffe66d88` : isMine ? `#ff6b9d44` : `${myColor}55`;
if (isGold) content = cellSize > 16 ? '⭐' : null;
if (isMine) content = cellSize > 16 ? '💣' : null;
} else if (cell === opponent) {
bg = isGold ? `#ffe66d55` : isMine ? `#ff6b9d33` : `${oppColor}55`;
} else {
// neutral & sichtbar
if (isGold) { bg = 'rgba(255,230,109,0.18)'; content = cellSize > 16 ? '⭐' : null; }
else if (isMine) { bg = 'rgba(255,107,157,0.15)'; content = cellSize > 16 ? '💣' : null; }
else if (clic) bg = `${myColor}22`;
}
if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; }
if (isOppHead) { border = `2px solid ${oppColor}`; }
if (isScoutPreview && !isHead) { bg = `rgba(255,230,109,0.2)`; border = `1px solid rgba(255,230,109,0.5)`; }
if (isHead && !isPend) { border = `2px solid ${myColor}`; }
if (isPend) { bg = myColor; border = `2px solid ${myColor}`; }
else if (clic && !isPend && !isBlast && !isHead) border = `1px solid ${myColor}44`;
return (
<div
key={`${r}-${c}`}
onClick={() => scoutMode ? setScoutPend({row:r,col:c}) : handleCellClick(r, c)}
onMouseEnter={() => scoutMode ? setScoutPend({row:r,col:c}) : setHover(`${r}-${c}`)}
onMouseLeave={() => { if(!scoutMode) setHover(null); }}
style={{
width:cellSize, height:cellSize, background:bg,
borderRadius:1, cursor:clic?'pointer':'default',
transition:'background 0.08s', border, boxSizing:'border-box',
display:'flex', alignItems:'center', justifyContent:'center',
fontSize: cellSize > 18 ? 10 : 7, lineHeight:1, userSelect:'none',
}}
>{content}</div>
);
};
return (
<div>
{/* Scoreboard */}
<div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:6 }}>
<div style={{ ...S.card, padding:'5px 8px', flex:1 }}>
<div style={{ color:myColor, fontFamily:'monospace', fontSize:9, letterSpacing:1, marginBottom:2 }}>DU</div>
<div style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700, lineHeight:1 }}>{myScore} <span style={{ fontSize:9, color:'rgba(255,255,255,0.3)' }}>Pkt</span></div>
</div>
<div style={{ textAlign:'center', color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:9 }}>🌫{fogCount}</div>
<div style={{ ...S.card, padding:'5px 8px', flex:1, textAlign:'right' }}>
<div style={{ color:oppColor, fontFamily:'monospace', fontSize:9, letterSpacing:1, marginBottom:2 }}>{oppName?.toUpperCase()}</div>
<div style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700, lineHeight:1 }}><span style={{ fontSize:9, color:'rgba(255,255,255,0.3)' }}>Pkt </span>{oppScore}</div>
</div>
</div>
{/* Mine-Alert */}
{mineAlert && (
<div style={{
background:'rgba(255,107,157,0.15)', border:'1px solid rgba(255,107,157,0.4)',
borderRadius:8, padding:'10px 14px', marginBottom:12,
display:'flex', alignItems:'center', gap:10,
}}>
<span style={{ fontSize:20 }}>💣</span>
<span style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:12, flex:1 }}>
{mineAlert.player === myId
? `💥 Mine! ${mineAlert.destroyedOwn ? mineAlert.destroyedOwn + ' deiner Felder zerstört — ' : ''}Zug verloren.`
: `💥 ${oppName} trat auf eine Mine! ${mineAlert.destroyedOpp ? mineAlert.destroyedOpp + ' deiner Felder zerstört!' : 'Kein Schaden.'} 😈`}
</span>
<button onClick={() => setMineAlert(null)} style={{ ...S.btn('#666666', true), padding:'2px 8px' }}></button>
</div>
)}
{/* Gewinner-Banner */}
{banner && (
<div style={{
background:`${banner.color}18`, border:`1px solid ${banner.color}44`,
borderRadius:10, padding:'12px 16px', marginBottom:14,
color:banner.color, fontFamily:'Space Mono,monospace', fontSize:15,
textAlign:'center', fontWeight:700, letterSpacing:1,
}}>{banner.text}</div>
)}
{/* Zug-Status */}
{game.status === 'active' && (
<div style={{ marginBottom:6 }}>
{!isMyTurn && !pending && (
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:11, textAlign:'center', padding:'4px 0' }}>
{oppName} ist dran
</div>
)}
{isMyTurn && !pending && !scoutMode && (
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
<div style={{ color:myColor, fontFamily:'monospace', fontSize:11, flex:1, textAlign:'center', padding:'4px 0' }}>
Baue vom Kopf aus Vorsicht vor Minen! 💣
</div>
<button onClick={() => { setScoutMode(true); setPending(null); }}
style={{ ...S.btn('#ffe66d', true), fontSize:10, flexShrink:0 }} title="3x3 Bereich aufdecken (kostet Zug)">
🔭 Kundschaften
</button>
</div>
)}
{isMyTurn && scoutMode && !scoutPend && (
<div style={{ display:'flex', alignItems:'center', gap:8, background:'rgba(255,230,109,0.1)', border:'1px solid rgba(255,230,109,0.4)', borderRadius:8, padding:'7px 10px' }}>
<span style={{ color:'#ffe66d', fontFamily:'monospace', fontSize:11, flex:1 }}>
🔭 Klicke einen Mittelpunkt zum Aufdecken (3×3)
</span>
<button onClick={() => { setScoutMode(false); setScoutPend(null); }} style={S.btn('#666666', true)}> Abbrechen</button>
</div>
)}
{isMyTurn && scoutMode && scoutPend && (
<div style={{ display:'flex', alignItems:'center', gap:8, background:'rgba(255,230,109,0.1)', border:'1px solid rgba(255,230,109,0.44)', borderRadius:8, padding:'7px 10px' }}>
<span style={{ color:'#ffe66d', fontFamily:'monospace', fontSize:11, flex:1 }}>
🔭 R{scoutPend.row+1} S{scoutPend.col+1} aufdecken kostet einen Zug
</span>
<button onClick={() => setScoutPend(null)} style={S.btn('#666666', true)}></button>
<button onClick={confirmScout} disabled={moving} style={S.btn('#ffe66d', true)}>{moving ? '…' : '✓ Aufdecken'}</button>
</div>
)}
{pending && !scoutMode && (
<div style={{ display:'flex', alignItems:'center', gap:8, background:`${myColor}12`, border:`1px solid ${myColor}44`, borderRadius:8, padding:'7px 10px' }}>
<span style={{ color:myColor, fontFamily:'monospace', fontSize:11, flex:1 }}>
R{pending.row+1} S{pending.col+1} bestätigen?
</span>
<button onClick={() => setPending(null)} style={S.btn('#666666', true)}></button>
<button onClick={confirmMove} disabled={moving} style={S.btn(MY_COLOR, true)}>{moving ? '…' : '✓ Ja'}</button>
</div>
)}
</div>
)}
{/* Legende */}
<div style={{ display:'flex', gap:6, marginBottom:4, flexWrap:'wrap' }}>
{[['⭐','Gold +3','#ffe66d'],['💣','Mine 💥','#ff6b9d'],['🪨','Felsen','rgba(255,255,255,0.35)'],['🌫️','Nebel','rgba(255,255,255,0.25)']].map(([icon,label,color]) => (
<div key={label} style={{ display:'flex', alignItems:'center', gap:4 }}>
<span style={{ fontSize:12 }}>{icon}</span>
<span style={{ color, fontFamily:'monospace', fontSize:10 }}>{label}</span>
</div>
))}
</div>
{/* Grid */}
<div style={{ overflowX:'auto', overflowY:'auto', WebkitOverflowScrolling:'touch', touchAction:'pinch-zoom', borderRadius:8, width:'fit-content' }}>
<div style={{ display:'grid', gridTemplateColumns:`repeat(${GRID}, ${cellSize}px)`, gridTemplateRows:`repeat(${GRID}, ${cellSize}px)`, gap:1, background:'rgba(255,255,255,0.04)', borderRadius:8, padding:3 }}>
{grid.map((row, r) => row.map((_, c) => renderCell(r, c)))}
</div>
</div>
</div>
);
}
// ── Spielzeile ────────────────────────────────────────────────────────────────
function GameRow({ g, myId, isAdmin, onSelect, onDelete }) {
const isMyTurn = g.status === 'active' && g.current_turn === myId;
const opp = g.owner_id === myId ? g.opp_name : g.owner_name;
const finished = g.status === 'finished' && (g.move_count || 0) >= 2;
let result = null;
if (g.status === 'finished') {
if (!g.winner_id) result = { label:'Unentschieden', color:'#ffe66d' };
else if (g.winner_id === myId) result = { label:'Gewonnen 🎉', color:MY_COLOR };
else result = { label:'Verloren', color:OPP_COLOR };
}
return (
<div style={{ ...S.card, marginBottom:8, display:'flex', alignItems:'center', gap:10, border: isMyTurn ? '1px solid rgba(78,205,196,0.35)' : '1px solid rgba(255,255,255,0.07)', padding:'12px 14px' }}>
<button onClick={() => onSelect(g.id)} style={{ flex:1, background:'none', border:'none', cursor:'pointer', textAlign:'left', padding:0 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, marginBottom:4 }}>
{isMyTurn && <span style={{ color:MY_COLOR, marginRight:6 }}></span>}
vs <strong>{opp}</strong>
</div>
<div style={{ color:'rgba(255,255,255,0.35)', fontSize:11, fontFamily:'monospace' }}>
{g.move_count || 0} Züge gespielt
</div>
</button>
{result
? <span style={{ color:result.color, fontSize:11, fontFamily:'monospace', flexShrink:0 }}>{result.label}</span>
: <span style={{ color: isMyTurn ? MY_COLOR : 'rgba(255,255,255,0.3)', fontSize:11, fontFamily:'monospace', flexShrink:0 }}>
{isMyTurn ? 'Du bist dran' : 'Gegner dran'}
</span>
}
{isAdmin && finished && (
<button onClick={e => { e.stopPropagation(); onDelete(g.id); }} style={{ ...S.btn('#ff6b9d', true), flexShrink:0 }} title="Löschen">🗑</button>
)}
</div>
);
}
// ── Spielliste ────────────────────────────────────────────────────────────────
function GameList({ games, myId, isAdmin, onSelect, onNew, onDelete, reloadKey }) {
const active = games.filter(g => g.status === 'active');
const finished = games.filter(g => g.status === 'finished' && (g.move_count || 0) >= 2);
return (
<div>
<button onClick={onNew} style={{ ...S.btn('#4ecdc4'), width:'100%', marginBottom:20, padding:'10px 14px', fontSize:13 }}>
+ Neues Spiel starten
</button>
{active.length > 0 && <>
<div style={{ ...S.head, marginBottom:10 }}>LAUFENDE SPIELE ({active.length})</div>
{active.map(g => <GameRow key={g.id} g={g} myId={myId} isAdmin={isAdmin} onSelect={onSelect} onDelete={onDelete} />)}
</>}
{finished.length > 0 && <>
<div style={{ ...S.head, marginTop:20, marginBottom:10 }}>BEENDETE SPIELE</div>
{finished.map(g => <GameRow key={g.id} g={g} myId={myId} isAdmin={isAdmin} onSelect={onSelect} onDelete={onDelete} finished />)}
</>}
{games.length === 0 && (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:13, textAlign:'center', paddingTop:40 }}>
Noch keine Spiele starte eines!
</div>
)}
<Leaderboard reloadKey={reloadKey} />
</div>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function HexWars({ toast }) {
const [games, setGames] = useState([]);
const [activeId, setActiveId] = useState(null);
const [game, setGame] = useState(null);
const [showNew, setShowNew] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const myId = getMyId();
const isAdmin = getMyRole() === 'admin';
const loadGames = useCallback(async () => {
try { setGames(await api('/tools/gebietseroberung')); }
catch { toast?.('Fehler beim Laden', 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { loadGames(); }, [loadGames]);
useEffect(() => {
if (!activeId) { setGame(null); return; }
api(`/tools/gebietseroberung/${activeId}`).then(g => {
setGame(g);
// Beendetes Spiel als gesehen markieren → Badge verschwindet
if (g.status === 'finished') {
api(`/tools/gebietseroberung/${activeId}/seen`, { body: {} }).catch(() => {});
}
}).catch(() => {});
}, [activeId]);
// Polling wenn Gegner dran
useEffect(() => {
if (!activeId || !game || !myId) return;
if (game.status !== 'active' || game.current_turn === myId) return;
const iv = setInterval(async () => {
const g = await api(`/tools/gebietseroberung/${activeId}`).catch(() => null);
if (!g) return;
setGame(g);
if (g.current_turn === myId) {
toast?.('Du bist dran! ⬡');
// games-Liste sofort aktualisieren
setGames(prev => prev.map(p => p.id === g.id
? { ...p, current_turn: g.current_turn, move_count: g.move_count }
: p
));
}
}, 10000);
return () => clearInterval(iv);
}, [activeId, game, myId]);
const handleMove = async (row, col) => {
const updated = await api(`/tools/gebietseroberung/${activeId}/move`, { body: { row, col } });
setGame(updated);
if (updated.status === 'finished') {
api(`/tools/gebietseroberung/${activeId}/seen`, { body: {} }).catch(() => {});
}
// Direkt den games-State patchen damit die Liste sofort stimmt (kein Warten auf loadGames)
setGames(prev => prev.map(g => g.id === updated.id
? { ...g, current_turn: updated.current_turn, move_count: updated.move_count, status: updated.status, winner_id: updated.winner_id }
: g
));
};
const handleScout = async (row, col) => {
const updated = await api(`/tools/gebietseroberung/${activeId}/scout`, { body: { row, col } });
setGame(updated);
setGames(prev => prev.map(g => g.id === updated.id
? { ...g, current_turn: updated.current_turn, move_count: updated.move_count }
: g
));
};
const handleResign = async () => {
if (!window.confirm('Wirklich aufgeben?')) return;
await api(`/tools/gebietseroberung/${activeId}/resign`, { body: {} });
const updated = await api(`/tools/gebietseroberung/${activeId}`);
setGame(updated);
setGames(prev => prev.map(g => g.id === updated.id ? { ...g, ...updated } : g));
toast('Du hast aufgegeben.');
};
const handleDelete = async (id) => {
if (!window.confirm('Spiel wirklich löschen?')) return;
try {
await api(`/tools/gebietseroberung/${id}`, { method: 'DELETE' });
setGames(prev => prev.filter(g => g.id !== id));
setReloadKey(k => k + 1);
toast('Spiel gelöscht.');
} catch(e) { toast?.(e.message || 'Fehler', 'error'); }
};
if (loading) return <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', padding:40, textAlign:'center' }}>Lade</div>;
return (
<div style={{ maxWidth:600, margin:'0 auto' }}>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
{activeId && (
<button onClick={() => { setActiveId(null); setGame(null); loadGames(); }} style={S.btn('#666666', true)}> Zurück</button>
)}
<h2 style={{ margin:0, fontSize:13, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400, flexShrink:0 }}>
HEX WARS
</h2>
<div style={{ flex:1 }} />
<button onClick={() => setShowHelp(true)} style={S.btn('#ffe66d', true)}>? Regeln</button>
{activeId && game?.status === 'active' && (
<button onClick={handleResign} style={S.btn('#ff6b9d', true)}></button>
)}
</div>
{!activeId
? <GameList games={games} myId={myId} isAdmin={isAdmin} onSelect={setActiveId} onNew={() => setShowNew(true)} onDelete={handleDelete} reloadKey={reloadKey} />
: game && myId
? <GameBoard game={game} myId={myId} onMove={handleMove} onScout={handleScout} toast={toast} />
: <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', textAlign:'center', paddingTop:40 }}>Lade Spiel</div>
}
{showNew && <NewGameModal onClose={() => setShowNew(false)} toast={toast} onCreated={id => { setShowNew(false); setActiveId(id); loadGames(); }} />}
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
</div>
);
}