feat: Hex Wars - Kundschaften, Mine-Alert auto-clear, Spielliste live

This commit is contained in:
2026-06-28 18:52:23 +02:00
parent a6b29c38ad
commit 247bcf5d44
2 changed files with 156 additions and 29 deletions

View File

@@ -141,9 +141,11 @@ function Leaderboard({ reloadKey }) {
}
// ── Spielfeld ─────────────────────────────────────────────────────────────────
function GameBoard({ game, myId, onMove, toast }) {
const [pending, setPending] = useState(null);
const [moving, setMoving] = useState(false);
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);
@@ -158,14 +160,18 @@ function GameBoard({ game, myId, onMove, toast }) {
// 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) return;
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);
// Blast-Zellen kurz highlighten
setAlertShownAt(game.move_count);
if (ev.blastCells?.length) {
const keys = new Set(ev.blastCells.map(([r,c]) => `${r}-${c}`));
setBlastHighlight(keys);
@@ -173,7 +179,7 @@ function GameBoard({ game, myId, onMove, toast }) {
}
}
} catch {}
}, [game.last_event]);
}, [game.last_event, game.move_count]);
// Schlangen-Köpfe aus Server-Response
const myHeadStr = game.myHead;
@@ -190,12 +196,11 @@ function GameBoard({ game, myId, onMove, toast }) {
};
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;
// 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;
@@ -256,14 +261,34 @@ function GameBoard({ game, myId, onMove, toast }) {
}, []);
// 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; // eigener Kopf
const isOppHead = game.status === 'finished' && oppHR === r && oppHC === c; // Gegner-Kopf nur bei Spielende
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;
@@ -293,14 +318,17 @@ function GameBoard({ game, myId, onMove, toast }) {
if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; }
if (isOppHead) { border = `2px solid ${oppColor}`; }
if (isHead && !isPend) { border = `2px solid ${myColor}`; } // eigener Kopf
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={() => handleCellClick(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',
@@ -362,12 +390,35 @@ function GameBoard({ game, myId, onMove, toast }) {
{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! 💣
{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>
)}
{pending && (
{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?
@@ -504,7 +555,13 @@ export default function HexWars({ toast }) {
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));
loadGames(); // Spielliste aktualisieren damit current_turn stimmt
};
const handleScout = async (row, col) => {
const updated = await api(`/tools/gebietseroberung/${activeId}/scout`, { body: { row, col } });
setGame(updated);
loadGames();
};
const handleResign = async () => {
@@ -547,7 +604,7 @@ export default function HexWars({ toast }) {
{!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} />
? <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>
}