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 (
⬡ SPIELREGELN

Ziel: Sammle mehr Punkte als dein Gegner — durch clevere Expansion und das Meiden von Fallen.

Ablauf:
Beide starten in gegenüberliegenden Ecken. Reihum wählst du ein freies Feld das an dein Gebiet grenzt (auch diagonal) und besetzt es. Tippe es an, bestätige — fertig.

FELDER & PUNKTE
Normales Feld — +1 Punkt Goldfeld — +3 Punkte, sehr wertvoll! 💣Sprengmine — Betreten zündet eine Explosion: alle 8 umliegenden Felder werden zerstört. Besetzte Felder gehen verloren, du verlierst deinen nächsten Zug. 🪨Felsen — unbesetzbar, blockiert Wege

🌫️ Nebel:
Du siehst nur dein Gebiet und die direkt angrenzenden Felder. Goldfelder und Minen bleiben verborgen bis du nahe genug herankommst — also aufgepasst!

Spielende:
Das Spiel endet wenn niemand mehr ziehen kann — oder wenn jemand doppelt so viele Punkte hat wie der Gegner (Dominanzsieg 👑). Wer mehr Punkte hat, gewinnt.

📱 Auf dem Handy: Pinch zum Zoomen, Feld antippen und bestätigen.
); } // ── 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 (
NEUES SPIEL
GEGNER WÄHLEN
); } // ── 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 (
TOPLISTE — SIEGE
{rows.length === 0 ?
Noch keine abgeschlossenen Spiele.
: rows.map((r,i) => (
{medals[i] || `${i+1}.`} {r.username} {r.wins} Siege
)) }
); } // ── 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]); const isAdj = (r, c) => DIRS8.some(([dr,dc]) => { const nr=r+dr, nc=c+dc; return nr>=0&&nr=0&&nc { if (!isMyTurn || game.status !== 'active') return false; if (grid[r][c] !== 0) return false; // T_ROCK kommt vom Server auch im Nebel nicht — Felsen sind immer sichtbar // T_FOG darf canClick NICHT blocken: Felder können klickbar aber noch im Nebel sein if (hasTerrain && terrain[r][c] === T_ROCK) return false; return isAdj(r, c); }; 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 vw = Math.min(window.innerWidth, 600); // Verfügbare Höhe: Viewport minus Header (~48px) minus Scoreboard (~72px) minus Status (~40px) minus Legende (~24px) minus Puffer (20px) const availH = window.innerHeight - 204; const byWidth = Math.floor((vw - 32) / GRID); const byHeight = Math.floor(availH / GRID); const cellSize = Math.max(10, Math.min(byWidth, byHeight)); // 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 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 (isPend) { bg = myColor; border = `2px solid ${myColor}`; } else if (clic && !isPend && !isBlast) border = `1px solid ${myColor}44`; return (
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}
); }; return (
{/* Scoreboard */}
DU
{myScore} Pkt
🌫️{fogCount}
{oppName?.toUpperCase()}
Pkt {oppScore}
{/* Mine-Alert */} {mineAlert && (
💣 {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.'} 😈`}
)} {/* Gewinner-Banner */} {banner && (
{banner.text}
)} {/* Zug-Status */} {game.status === 'active' && (
{!isMyTurn && !pending && (
⏳ {oppName} ist dran…
)} {isMyTurn && !pending && (
▶ Wähle ein Feld — Vorsicht vor Minen! 💣
)} {pending && (
✦ R{pending.row+1} S{pending.col+1} — bestätigen?
)}
)} {/* Legende */}
{[['⭐','Gold +3','#ffe66d'],['💣','Mine −3','#ff6b9d'],['🪨','Felsen','rgba(255,255,255,0.35)'],['🌫️','Nebel','rgba(255,255,255,0.25)']].map(([icon,label,color]) => (
{icon} {label}
))}
{/* Grid */}
{grid.map((row, r) => row.map((_, c) => renderCell(r, c)))}
); } // ── 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 (
{result ? {result.label} : {isMyTurn ? 'Du bist dran' : 'Gegner dran'} } {isAdmin && finished && ( )}
); } // ── 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 (
{active.length > 0 && <>
LAUFENDE SPIELE ({active.length})
{active.map(g => )} } {finished.length > 0 && <>
BEENDETE SPIELE
{finished.map(g => )} } {games.length === 0 && (
Noch keine Spiele — starte eines!
)}
); } // ── 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
Lade…
; return (
{activeId && ( )}

⬡ HEX WARS

{activeId && game?.status === 'active' && ( )}
{!activeId ? setShowNew(true)} onDelete={handleDelete} reloadKey={reloadKey} /> : game && myId ? :
Lade Spiel…
} {showNew && setShowNew(false)} toast={toast} onCreated={id => { setShowNew(false); setActiveId(id); loadGames(); }} />} {showHelp && setShowHelp(false)} />}
); }