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

547 lines
27 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)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:1000, padding:16, overflowY:'auto' }}>
<div style={{ ...S.card, maxWidth:460, width:'100%' }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:18 }}>
<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={{ 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 zerstört. Besetzte Felder gehen verloren, 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:'#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>
);
}
// ── 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, toast }) {
const [pending, setPending] = useState(null);
const [moving, setMoving] = useState(false);
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());
useEffect(() => {
if (!game.last_event) return;
try {
const ev = JSON.parse(game.last_event);
if (ev.type === 'mine') {
setMineAlert(ev);
// Blast-Zellen kurz highlighten
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]);
// Schlangen-Kopf aus Server-Response
const myHeadStr = game.myHead;
const myHead = myHeadStr ? myHeadStr.split(',').map(Number) : null;
const [myHR, myHC] = myHead ?? [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 (!isMyTurn || game.status !== 'active') return false;
if (grid[r][c] !== 0) return false;
if (hasTerrain && terrain[r][c] === T_ROCK) return false;
// Schlangen-Regel: nur Felder an den Kopf
if (myHead) return isAdjacentToHead(r, c);
// Fallback für alte Spiele ohne head
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 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; // eigener Kopf
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 (isHead && !isPend) { border = `2px solid ${myColor}`; } // Kopf immer markieren
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={() => handleCellClick(r, c)}
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 && (
<div style={{ color:myColor, fontFamily:'monospace', fontSize:11, textAlign:'center', padding:'4px 0' }}>
Baue vom Kopf aus Vorsicht vor Minen! 💣
</div>
)}
{pending && (
<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 3','#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(setGame).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! ⬡');
}, 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);
setGames(prev => prev.map(g => g.id === updated.id ? { ...g, ...updated } : 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); }} 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} 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>
);
}