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

@@ -30,6 +30,8 @@ const router = express.Router();
['last_event', 'TEXT'], ['last_event', 'TEXT'],
['owner_head', 'TEXT'], ['owner_head', 'TEXT'],
['opp_head', 'TEXT'], ['opp_head', 'TEXT'],
['owner_scouts', 'TEXT'],
['opp_scouts', 'TEXT'],
]) { ]) {
if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`); if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`);
} }
@@ -69,7 +71,7 @@ function generateTerrain(ownerHead, oppHead) {
} }
// ── Fog of War ──────────────────────────────────────────────────────────────── // ── Fog of War ────────────────────────────────────────────────────────────────
function computeFog(grid, playerId) { function computeFog(grid, playerId, scouts = []) {
const visible = Array(GRID).fill(null).map(() => Array(GRID).fill(false)); const visible = Array(GRID).fill(null).map(() => Array(GRID).fill(false));
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) { for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
if (grid[r][c] === playerId) { if (grid[r][c] === playerId) {
@@ -80,6 +82,13 @@ function computeFog(grid, playerId) {
} }
} }
} }
// Scout-Aufdeckungen: 3x3 um jeden Scout-Mittelpunkt
for (const [sr,sc] of scouts) {
for (let dr=-1;dr<=1;dr++) for (let dc=-1;dc<=1;dc++) {
const nr=sr+dr, nc=sc+dc;
if (nr>=0&&nr<GRID&&nc>=0&&nc<GRID) visible[nr][nc] = true;
}
}
return visible; return visible;
} }
@@ -228,13 +237,17 @@ function getGameForUser(id, requesterId) {
return { ...game, myScore:0, oppScore:0, myHead, oppHead }; return { ...game, myScore:0, oppScore:0, myHead, oppHead };
} }
// Scouts des anfragenden Spielers laden
const myScoutsRaw = requesterId === game.owner_id ? game.owner_scouts : game.opp_scouts;
const myScouts = myScoutsRaw ? JSON.parse(myScoutsRaw) : [];
// Bei beendetem Spiel: vollständiges Grid ohne Fog // Bei beendetem Spiel: vollständiges Grid ohne Fog
let maskedGrid, maskedTerrain; let maskedGrid, maskedTerrain;
if (game.status === 'finished') { if (game.status === 'finished') {
maskedGrid = grid; maskedGrid = grid;
maskedTerrain = terrain; maskedTerrain = terrain;
} else { } else {
const visible = computeFog(grid, requesterId); const visible = computeFog(grid, requesterId, myScouts);
maskedGrid = grid.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : (cell===requesterId ? cell : 0))); maskedGrid = grid.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : (cell===requesterId ? cell : 0)));
maskedTerrain = terrain.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : -1)); maskedTerrain = terrain.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : -1));
} }
@@ -247,6 +260,7 @@ function getGameForUser(id, requesterId) {
oppScore: oppSc, oppScore: oppSc,
myHead, myHead,
oppHead, oppHead,
myScouts,
}; };
} }
@@ -442,10 +456,14 @@ router.post('/:id/move', authenticate, (req, res) => {
winnerId = ms > os ? me : (os > ms ? opponent : null); winnerId = ms > os ? me : (os > ms ? opponent : null);
} }
// last_event: nach jedem Zug das vorherige Event clearen (war vom letzten Zug)
// Nur das neue Event (falls Mine) bleibt stehen
const finalEvent = lastEvent; // null wenn kein Mine-Treffer
db.prepare(` db.prepare(`
UPDATE geo_games SET grid=?, terrain=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?, UPDATE geo_games SET grid=?, terrain=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?,
owner_head=?, opp_head=?, updated_at=datetime('now','localtime') WHERE id=? owner_head=?, opp_head=?, updated_at=datetime('now','localtime') WHERE id=?
`).run(JSON.stringify(grid), JSON.stringify(terrain), nextTurn, status, winnerId, newMoves, lastEvent, newOwnerHead, newOppHead, game.id); `).run(JSON.stringify(grid), JSON.stringify(terrain), nextTurn, status, winnerId, newMoves, finalEvent, newOwnerHead, newOppHead, game.id);
// Pushover // Pushover
if (status === 'finished') { if (status === 'finished') {
@@ -474,6 +492,58 @@ router.post('/:id/move', authenticate, (req, res) => {
res.json(getGameForUser(game.id, me)); res.json(getGameForUser(game.id, me));
}); });
// ── Scout: 3x3 Bereich aufdecken (kostet Zug) ───────────────────────────────
router.post('/:id/scout', authenticate, (req, res) => {
const me = uid(req);
const { row, col } = req.body;
if (row === undefined || col === undefined) return res.status(400).json({ error: 'row/col fehlt' });
const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
if (game.status !== 'active') return res.status(400).json({ error: 'Spiel beendet' });
if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' });
const opponent = game.owner_id === me ? game.opponent_id : game.owner_id;
const newMoves = (game.move_count || 0) + 1;
// Scouts des Spielers laden und neuen hinzufügen
const isOwner = game.owner_id === me;
const scoutsKey = isOwner ? 'owner_scouts' : 'opp_scouts';
const existingRaw = game[scoutsKey];
const existing = existingRaw ? JSON.parse(existingRaw) : [];
existing.push([row, col]);
const newScoutsStr = JSON.stringify(existing);
// Zug weitergeben (Scout kostet Zug)
const grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]');
let status = 'active', winnerId = null;
// Schnellsieg-Check
if (newMoves >= 10 && terrain.length) {
const ms = calcScore(grid, terrain, me);
const os = calcScore(grid, terrain, opponent);
if (ms >= os*2 && os > 0) status = 'finished';
else if (os >= ms*2 && ms > 0) status = 'finished';
if (status === 'finished') {
winnerId = ms > os ? me : (os > ms ? opponent : null);
}
}
const updateFields = isOwner
? 'owner_scouts=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=NULL'
: 'opp_scouts=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=NULL';
db.prepare(`UPDATE geo_games SET ${updateFields}, updated_at=datetime('now','localtime') WHERE id=?`)
.run(newScoutsStr, opponent, status, winnerId, newMoves, game.id);
const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand';
sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gekundschaftet — jetzt bist du dran!`);
res.json(getGameForUser(game.id, me));
});
// ── Aufgeben ────────────────────────────────────────────────────────────────── // ── Aufgeben ──────────────────────────────────────────────────────────────────
router.post('/:id/resign', authenticate, (req, res) => { router.post('/:id/resign', authenticate, (req, res) => {
const me = uid(req); const me = uid(req);

View File

@@ -141,9 +141,11 @@ function Leaderboard({ reloadKey }) {
} }
// ── Spielfeld ───────────────────────────────────────────────────────────────── // ── Spielfeld ─────────────────────────────────────────────────────────────────
function GameBoard({ game, myId, onMove, toast }) { function GameBoard({ game, myId, onMove, onScout, toast }) {
const [pending, setPending] = useState(null); const [pending, setPending] = useState(null);
const [moving, setMoving] = useState(false); 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 [mineAlert, setMineAlert] = useState(null); // letztes Mine-Event
const grid = JSON.parse(game.grid); const grid = JSON.parse(game.grid);
@@ -158,14 +160,18 @@ function GameBoard({ game, myId, onMove, toast }) {
// Mine-Event anzeigen wenn frisch // Mine-Event anzeigen wenn frisch
const [blastHighlight, setBlastHighlight] = useState(new Set()); const [blastHighlight, setBlastHighlight] = useState(new Set());
const [alertShownAt, setAlertShownAt] = useState(null); // move_count beim Anzeigen
useEffect(() => { useEffect(() => {
if (!game.last_event) return; if (!game.last_event) {
setMineAlert(null); // Event wurde vom Server geclearet (nächster Zug)
return;
}
try { try {
const ev = JSON.parse(game.last_event); const ev = JSON.parse(game.last_event);
if (ev.type === 'mine') { if (ev.type === 'mine') {
setMineAlert(ev); setMineAlert(ev);
// Blast-Zellen kurz highlighten setAlertShownAt(game.move_count);
if (ev.blastCells?.length) { if (ev.blastCells?.length) {
const keys = new Set(ev.blastCells.map(([r,c]) => `${r}-${c}`)); const keys = new Set(ev.blastCells.map(([r,c]) => `${r}-${c}`));
setBlastHighlight(keys); setBlastHighlight(keys);
@@ -173,7 +179,7 @@ function GameBoard({ game, myId, onMove, toast }) {
} }
} }
} catch {} } catch {}
}, [game.last_event]); }, [game.last_event, game.move_count]);
// Schlangen-Köpfe aus Server-Response // Schlangen-Köpfe aus Server-Response
const myHeadStr = game.myHead; const myHeadStr = game.myHead;
@@ -190,12 +196,11 @@ function GameBoard({ game, myId, onMove, toast }) {
}; };
const canClick = (r, c) => { const canClick = (r, c) => {
if (scoutMode) return false; // im Scout-Modus kein normaler Zug
if (!isMyTurn || game.status !== 'active') return false; if (!isMyTurn || game.status !== 'active') return false;
if (grid[r][c] !== 0) return false; if (grid[r][c] !== 0) return false;
if (hasTerrain && terrain[r][c] === T_ROCK) return false; if (hasTerrain && terrain[r][c] === T_ROCK) return false;
// Schlangen-Regel: nur Felder an den Kopf
if (myHead) return isAdjacentToHead(r, c); if (myHead) return isAdjacentToHead(r, c);
// Fallback für alte Spiele ohne head
return DIRS8.some(([dr,dc]) => { return DIRS8.some(([dr,dc]) => {
const nr=r+dr, nc=c+dc; const nr=r+dr, nc=c+dc;
return nr>=0&&nr<GRID&&nc>=0&&nc<GRID&&grid[nr][nc]===myId; 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 // 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 renderCell = (r, c) => {
const cell = grid[r][c]; const cell = grid[r][c];
const t = hasTerrain ? terrain[r][c] : T_NORMAL; const t = hasTerrain ? terrain[r][c] : T_NORMAL;
const clic = canClick(r, c); const clic = canClick(r, c);
const isPend = pending?.row === r && pending?.col === c; const isPend = pending?.row === r && pending?.col === c;
const isBlast = blastHighlight.has(`${r}-${c}`); const isBlast = blastHighlight.has(`${r}-${c}`);
const isHead = myHR === r && myHC === c; // eigener Kopf const isHead = myHR === r && myHC === c;
const isOppHead = game.status === 'finished' && oppHR === r && oppHC === c; // Gegner-Kopf nur bei Spielende 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 isFog = t === T_FOG;
const isRock = t === T_ROCK; const isRock = t === T_ROCK;
const isGold = t === T_GOLD; const isGold = t === T_GOLD;
@@ -293,14 +318,17 @@ function GameBoard({ game, myId, onMove, toast }) {
if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; } if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; }
if (isOppHead) { border = `2px solid ${oppColor}`; } 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}`; } if (isPend) { bg = myColor; border = `2px solid ${myColor}`; }
else if (clic && !isPend && !isBlast && !isHead) border = `1px solid ${myColor}44`; else if (clic && !isPend && !isBlast && !isHead) border = `1px solid ${myColor}44`;
return ( return (
<div <div
key={`${r}-${c}`} 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={{ style={{
width:cellSize, height:cellSize, background:bg, width:cellSize, height:cellSize, background:bg,
borderRadius:1, cursor:clic?'pointer':'default', borderRadius:1, cursor:clic?'pointer':'default',
@@ -362,12 +390,35 @@ function GameBoard({ game, myId, onMove, toast }) {
{oppName} ist dran {oppName} ist dran
</div> </div>
)} )}
{isMyTurn && !pending && ( {isMyTurn && !pending && !scoutMode && (
<div style={{ color:myColor, fontFamily:'monospace', fontSize:11, textAlign:'center', padding:'4px 0' }}> <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! 💣 Baue vom Kopf aus Vorsicht vor Minen! 💣
</div> </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' }}> <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 }}> <span style={{ color:myColor, fontFamily:'monospace', fontSize:11, flex:1 }}>
R{pending.row+1} S{pending.col+1} bestätigen? 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 handleMove = async (row, col) => {
const updated = await api(`/tools/gebietseroberung/${activeId}/move`, { body: { row, col } }); const updated = await api(`/tools/gebietseroberung/${activeId}/move`, { body: { row, col } });
setGame(updated); 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 () => { const handleResign = async () => {
@@ -547,7 +604,7 @@ export default function HexWars({ toast }) {
{!activeId {!activeId
? <GameList games={games} myId={myId} isAdmin={isAdmin} onSelect={setActiveId} onNew={() => setShowNew(true)} onDelete={handleDelete} reloadKey={reloadKey} /> ? <GameList games={games} myId={myId} isAdmin={isAdmin} onSelect={setActiveId} onNew={() => setShowNew(true)} onDelete={handleDelete} reloadKey={reloadKey} />
: game && myId : 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> : <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', textAlign:'center', paddingTop:40 }}>Lade Spiel</div>
} }