From 247bcf5d4403634bf99c0a649cd363f21211429b Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 28 Jun 2026 18:52:23 +0200 Subject: [PATCH] feat: Hex Wars - Kundschaften, Mine-Alert auto-clear, Spielliste live --- backend/src/tools/gebietseroberung/routes.js | 92 ++++++++++++++++--- frontend/src/tools/gebietseroberung.jsx | 93 ++++++++++++++++---- 2 files changed, 156 insertions(+), 29 deletions(-) diff --git a/backend/src/tools/gebietseroberung/routes.js b/backend/src/tools/gebietseroberung/routes.js index 0d975ca..f8050f8 100644 --- a/backend/src/tools/gebietseroberung/routes.js +++ b/backend/src/tools/gebietseroberung/routes.js @@ -25,11 +25,13 @@ const router = express.Router(); `); } for (const [col, def] of [ - ['move_count', 'INTEGER NOT NULL DEFAULT 0'], - ['terrain', 'TEXT'], - ['last_event', 'TEXT'], - ['owner_head', 'TEXT'], - ['opp_head', 'TEXT'], + ['move_count', 'INTEGER NOT NULL DEFAULT 0'], + ['terrain', 'TEXT'], + ['last_event', 'TEXT'], + ['owner_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}`); } @@ -69,7 +71,7 @@ function generateTerrain(ownerHead, oppHead) { } // ── Fog of War ──────────────────────────────────────────────────────────────── -function computeFog(grid, playerId) { +function computeFog(grid, playerId, scouts = []) { const visible = Array(GRID).fill(null).map(() => Array(GRID).fill(false)); for (let r=0;r=0&&nr=0&&nc 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)); } return { ...game, - grid: JSON.stringify(maskedGrid), - terrain: JSON.stringify(maskedTerrain), + grid: JSON.stringify(maskedGrid), + terrain: JSON.stringify(maskedTerrain), myScore, - oppScore: oppSc, + oppScore: oppSc, myHead, oppHead, + myScouts, }; } @@ -442,10 +456,14 @@ router.post('/:id/move', authenticate, (req, res) => { 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(` 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=? - `).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 if (status === 'finished') { @@ -474,6 +492,58 @@ router.post('/:id/move', authenticate, (req, res) => { 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 ────────────────────────────────────────────────────────────────── router.post('/:id/resign', authenticate, (req, res) => { const me = uid(req); diff --git a/frontend/src/tools/gebietseroberung.jsx b/frontend/src/tools/gebietseroberung.jsx index 025ba01..2ed5d13 100644 --- a/frontend/src/tools/gebietseroberung.jsx +++ b/frontend/src/tools/gebietseroberung.jsx @@ -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=0&&nc { + 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 (
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…
)} - {isMyTurn && !pending && ( -
- ▶ Baue vom Kopf aus — Vorsicht vor Minen! 💣 + {isMyTurn && !pending && !scoutMode && ( +
+
+ ▶ Baue vom Kopf aus — Vorsicht vor Minen! 💣 +
+
)} - {pending && ( + {isMyTurn && scoutMode && !scoutPend && ( +
+ + 🔭 Klicke einen Mittelpunkt zum Aufdecken (3×3) + + +
+ )} + {isMyTurn && scoutMode && scoutPend && ( +
+ + 🔭 R{scoutPend.row+1} S{scoutPend.col+1} aufdecken — kostet einen Zug + + + +
+ )} + {pending && !scoutMode && (
✦ 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 ? setShowNew(true)} onDelete={handleDelete} reloadKey={reloadKey} /> : game && myId - ? + ? :
Lade Spiel…
}