522 lines
26 KiB
JavaScript
522 lines
26 KiB
JavaScript
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. Reihum wählst du ein freies Feld das an dein Gebiet grenzt (auch diagonal) und besetzt es. Tippe es 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]);
|
||
|
||
const isAdj = (r, c) => 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 canClick = (r, c) => {
|
||
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 (
|
||
<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:8 }}>
|
||
<div style={{ ...S.card, padding:'6px 10px', 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:18, fontWeight:700, lineHeight:1 }}>{myScore} <span style={{ fontSize:10, 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:'6px 10px', 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:18, fontWeight:700, lineHeight:1 }}><span style={{ fontSize:10, 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' }}>
|
||
▶ Wähle ein Feld — 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:8, marginBottom:6, 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', maxHeight:'62vh', borderRadius:8 }}>
|
||
<div style={{ display:'grid', gridTemplateColumns:`repeat(${GRID}, ${cellSize}px)`, gap:1, background:'rgba(255,255,255,0.04)', borderRadius:8, padding:3, width:'fit-content' }}>
|
||
{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:10, marginBottom:12, flexWrap:'wrap' }}>
|
||
{activeId && (
|
||
<button onClick={() => { setActiveId(null); setGame(null); }} style={S.btn('#666666', true)}>← Zurück</button>
|
||
)}
|
||
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
|
||
⬡ 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)}>⚑ Aufgeben</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>
|
||
);
|
||
}
|