diff --git a/backend/src/tools/gebietseroberung/routes.js b/backend/src/tools/gebietseroberung/routes.js index 8ade5e5..beb1b95 100644 --- a/backend/src/tools/gebietseroberung/routes.js +++ b/backend/src/tools/gebietseroberung/routes.js @@ -13,74 +13,156 @@ const router = express.Router(); owner_id INTEGER NOT NULL, opponent_id INTEGER NOT NULL, grid TEXT NOT NULL, + terrain TEXT NOT NULL DEFAULT '[]', + fog_owner TEXT NOT NULL DEFAULT '[]', + fog_opp TEXT NOT NULL DEFAULT '[]', current_turn INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'active', winner_id INTEGER, move_count INTEGER NOT NULL DEFAULT 0, + last_event TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) ); `); } - // move_count nachrüsten falls Tabelle schon existiert - if (!cols.includes('move_count')) { - db.exec(`ALTER TABLE geo_games ADD COLUMN move_count INTEGER NOT NULL DEFAULT 0`); + // Spalten nachrüsten falls Tabelle schon existiert + for (const col of ['move_count','terrain','fog_owner','fog_opp','last_event']) { + if (!cols.includes(col)) { + const def = col === 'move_count' ? 'INTEGER NOT NULL DEFAULT 0' : 'TEXT'; + db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`); + } } })(); -const GRID = 20; +// ── Konstanten ──────────────────────────────────────────────────────────────── +const GRID = 20; +const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]]; +// Terrain-Werte (in separatem 20x20 Array gespeichert, unabhängig von grid) +const T_NORMAL = 0; +const T_GOLD = 1; // +3 Punkte +const T_MINE = 2; // -3 Punkte + Zug verlieren +const T_ROCK = 3; // unbesetzbar + +// ── Terrain generieren ──────────────────────────────────────────────────────── +function generateTerrain(ownerId, oppId) { + const t = Array(GRID).fill(null).map(() => Array(GRID).fill(T_NORMAL)); + + // Startecken und ihre direkten Nachbarn freihalten + const safeZone = new Set(); + for (const [dr,dc] of [[0,0],[0,1],[1,0],[1,1],[-1,0],[0,-1]]) { + safeZone.add(`0,${Math.max(0,dc)}`); + safeZone.add(`${Math.max(0,dr)},0`); + safeZone.add(`${Math.min(GRID-1,GRID-1+dr)},${Math.min(GRID-1,GRID-1+dc)}`); + } + // Einfacher: 2x2 Startbereich je Ecke freihalten + for (let r=0;r<3;r++) for (let c=0;c<3;c++) { safeZone.add(`${r},${c}`); safeZone.add(`${GRID-1-r},${GRID-1-c}`); } + + const cells = []; + for (let r=0;r0;i--) { const j=Math.floor(Math.random()*(i+1)); [cells[i],cells[j]]=[cells[j],cells[i]]; } + + let idx = 0; + // 12 Goldfelder + for (let i=0;i<12&&idx Array(GRID).fill(false)); + for (let r=0;r=0&&nr=0&&nc Array(GRID).fill(0)); grid[0][0] = ownerId; - grid[GRID - 1][GRID - 1] = oppId; - return JSON.stringify(grid); + grid[GRID-1][GRID-1] = oppId; + return grid; } -// Diagonal erlaubt: alle 8 Richtungen function isAdjacent(grid, row, col, playerId) { - const dirs = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]]; - for (const [dr, dc] of dirs) { - const r = row + dr, c = col + dc; - if (r >= 0 && r < GRID && c >= 0 && c < GRID && grid[r][c] === playerId) return true; + for (const [dr,dc] of DIRS8) { + const r=row+dr, c=col+dc; + if (r>=0&&r=0&&c {}); } -// Spiel mit JOIN laden (inkl. owner_name / opp_name) -function getGame(id) { - return db.prepare(` +// Spiel laden + Fog-of-War für anfragenden User anwenden +function getGameForUser(id, requesterId) { + const game = db.prepare(` SELECT g.*, u1.username as owner_name, u2.username as opp_name FROM geo_games g JOIN users u1 ON u1.id = g.owner_id JOIN users u2 ON u2.id = g.opponent_id WHERE g.id = ? `).get(id); + if (!game) return null; + + const grid = JSON.parse(game.grid); + const terrain = JSON.parse(game.terrain || '[]'); + if (!terrain.length) return game; // Migration: altes Spiel ohne Terrain + + // Fog berechnen + const visible = computeFog(grid, terrain, requesterId); + + // Grid maskieren: versteckte Gegner-Felder als 0 zeigen, Terrain nur wo sichtbar + const maskedGrid = grid.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : (cell === requesterId ? cell : 0))); + const maskedTerrain = terrain.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : -1)); // -1 = Nebel + + return { ...game, grid: JSON.stringify(maskedGrid), terrain: JSON.stringify(maskedTerrain), fog: JSON.stringify(visible) }; } const uid = req => req.user.id; @@ -97,28 +179,23 @@ router.get('/', authenticate, (req, res) => { AND NOT (g.status='finished' AND g.move_count < 2) ORDER BY g.updated_at DESC `).all(me, me); + // Für Listenansicht kein Fog nötig — nur eigene Felder zählen res.json(games); }); // ── Topliste ────────────────────────────────────────────────────────────────── router.get('/leaderboard', authenticate, (req, res) => { - // Nur Spiele die wirklich gespielt wurden (mind. 1 Zug pro Spieler = move_count >= 2) - // und nicht durch Aufgeben ohne Züge beendet wurden const rows = db.prepare(` SELECT u.username, COUNT(*) as wins FROM geo_games g JOIN users u ON u.id = g.winner_id - WHERE g.status = 'finished' - AND g.winner_id IS NOT NULL - AND g.move_count >= 2 - GROUP BY g.winner_id - ORDER BY wins DESC - LIMIT 20 + WHERE g.status='finished' AND g.winner_id IS NOT NULL AND g.move_count >= 2 + GROUP BY g.winner_id ORDER BY wins DESC LIMIT 20 `).all(); res.json(rows); }); -// ── Alle User (für Einladen) ────────────────────────────────────────────────── +// ── Alle User ───────────────────────────────────────────────────────────────── router.get('/users', authenticate, (req, res) => { const me = uid(req); const isAdmin = req.user?.role === 'admin'; @@ -128,10 +205,10 @@ router.get('/users', authenticate, (req, res) => { res.json(users); }); -// ── Einzelnes Spiel ─────────────────────────────────────────────────────────── +// ── Einzelnes Spiel (mit Fog) ───────────────────────────────────────────────── router.get('/:id', authenticate, (req, res) => { const me = uid(req); - const game = getGame(req.params.id); + const game = getGameForUser(req.params.id, me); 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' }); @@ -143,19 +220,21 @@ router.post('/', authenticate, (req, res) => { const me = uid(req); const { opponent_id } = req.body; if (!opponent_id) return res.status(400).json({ error: 'Gegner fehlt' }); - if (opponent_id === me) return res.status(400).json({ error: 'Du kannst nicht gegen dich spielen' }); + if (Number(opponent_id) === me) return res.status(400).json({ error: 'Du kannst nicht gegen dich spielen' }); const opp = db.prepare('SELECT id, username FROM users WHERE id=?').get(opponent_id); if (!opp) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); - const grid = createGrid(me, opponent_id); + const grid = createGrid(me, Number(opponent_id)); + const terrain = generateTerrain(me, Number(opponent_id)); + const result = db.prepare(` - INSERT INTO geo_games (owner_id, opponent_id, grid, current_turn, move_count) - VALUES (?, ?, ?, ?, 0) - `).run(me, opponent_id, grid, me); + INSERT INTO geo_games (owner_id, opponent_id, grid, terrain, current_turn, move_count) + VALUES (?, ?, ?, ?, ?, 0) + `).run(me, Number(opponent_id), JSON.stringify(grid), JSON.stringify(terrain), me); const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; - sendPush(opponent_id, '⬡ Hex Wars', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`); + sendPush(Number(opponent_id), '⬡ Hex Wars', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`); res.json({ id: result.lastInsertRowid }); }); @@ -168,64 +247,79 @@ router.post('/:id/move', authenticate, (req, res) => { const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); if (!game) return res.status(404).json({ error: 'Spiel nicht gefunden' }); - if (game.owner_id !== me && game.opponent_id !== me) - return res.status(403).json({ error: 'Kein Zugriff' }); + 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 grid = JSON.parse(game.grid); - if (row < 0 || row >= GRID || col < 0 || col >= GRID) - return res.status(400).json({ error: 'Ungültige Position' }); - if (grid[row][col] !== 0) - return res.status(400).json({ error: 'Zelle bereits belegt' }); - if (!isAdjacent(grid, row, col, me)) - return res.status(400).json({ error: 'Zelle grenzt nicht an dein Gebiet' }); + const grid = JSON.parse(game.grid); + const terrain = JSON.parse(game.terrain || '[]'); + if (row < 0 || row >= GRID || col < 0 || col >= GRID) return res.status(400).json({ error: 'Ungültige Position' }); + if (grid[row][col] !== 0) return res.status(400).json({ error: 'Zelle belegt' }); + if (terrain.length && terrain[row][col] === T_ROCK) return res.status(400).json({ error: 'Felsen — nicht betretbar' }); + if (!isAdjacent(grid, row, col, me)) return res.status(400).json({ error: 'Nicht angrenzend' }); + + const opponent = game.owner_id === me ? game.opponent_id : game.owner_id; + const newMoves = (game.move_count || 0) + 1; + + // Zug ausführen grid[row][col] = me; - const opponent = game.owner_id === me ? game.opponent_id : game.owner_id; - const newMoves = (game.move_count || 0) + 1; - let nextTurn = opponent; - let status = 'active'; - let winnerId = null; - if (!canMove(grid, opponent)) { - if (!canMove(grid, me)) { - status = 'finished'; - const mc = countCells(grid, me); - const oc = countCells(grid, opponent); - winnerId = mc > oc ? me : (oc > mc ? opponent : null); - } else { - nextTurn = me; // Gegner überspringen - } + // Mine getroffen? + let mineHit = false; + let lastEvent = null; + if (terrain.length && terrain[row][col] === T_MINE) { + mineHit = true; + lastEvent = JSON.stringify({ type:'mine', player: me, row, col }); } - db.prepare(` - UPDATE geo_games SET grid=?, current_turn=?, status=?, winner_id=?, move_count=?, - updated_at=datetime('now','localtime') WHERE id=? - `).run(JSON.stringify(grid), nextTurn, status, winnerId, newMoves, game.id); - - // Pushover const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; const oppName = db.prepare('SELECT username FROM users WHERE id=?').get(opponent)?.username || 'Jemand'; - if (status === 'finished') { - const mc = countCells(grid, me); - const oc = countCells(grid, opponent); - if (winnerId === me) { - sendPush(opponent, '⬡ Hex Wars beendet', `${myName} hat gewonnen! ${mc} vs ${oc} Felder.`); - sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen! ${mc} vs ${oc} Felder. 🎉`); - } else if (winnerId === opponent) { - sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen. ${mc} vs ${oc} Felder.`); - sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen! ${oc} vs ${mc} Felder. 🎉`); - } else { - sendPush(me, '⬡ Hex Wars — Unentschieden!', `${mc} vs ${oc} Felder.`); - sendPush(opponent, '⬡ Hex Wars — Unentschieden!', `${oc} vs ${mc} Felder.`); - } - } else if (nextTurn === opponent) { - sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!`); + // Nächster Zug: Mine = Zug verlieren (Gegner dran), sonst normal + let nextTurn = mineHit ? opponent : opponent; + let status = 'active'; + let winnerId = null; + + // Spielende prüfen + const myCanMove = canMove(grid, terrain, me); + const oppCanMove = canMove(grid, terrain, opponent); + + if (!oppCanMove && !myCanMove) { + status = 'finished'; + const ms = calcScore(grid, terrain, me); + const os = calcScore(grid, terrain, opponent); + winnerId = ms > os ? me : (os > ms ? opponent : null); + } else if (!oppCanMove) { + nextTurn = me; // Gegner überspringen } - res.json(getGame(game.id)); + db.prepare(` + UPDATE geo_games SET grid=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?, + updated_at=datetime('now','localtime') WHERE id=? + `).run(JSON.stringify(grid), nextTurn, status, winnerId, newMoves, lastEvent, game.id); + + // Pushover + if (status === 'finished') { + const ms = calcScore(grid, terrain, me); + const os = calcScore(grid, terrain, opponent); + if (winnerId === me) { + sendPush(opponent, '⬡ Hex Wars beendet', `${myName} hat gewonnen! ${ms} vs ${os} Punkte.`); + sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen! ${ms} vs ${os} Punkte. 🎉`); + } else if (winnerId === opponent) { + sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen. ${ms} vs ${os} Punkte.`); + sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen! ${os} vs ${ms} Punkte. 🎉`); + } else { + sendPush(me, '⬡ Hex Wars — Unentschieden!', `${ms} vs ${os} Punkte.`); + sendPush(opponent, '⬡ Hex Wars — Unentschieden!', `${os} vs ${ms} Punkte.`); + } + } else { + const mineMsg = mineHit ? ' 💥 MINE! Du verlierst deinen nächsten Zug!' : ''; + if (nextTurn === opponent) sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!${mineMsg}`); + if (mineHit) sendPush(me, '⬡ Hex Wars — 💥 Mine!', 'Du hast eine Mine getroffen! Dein nächster Zug gehört dem Gegner.'); + } + + res.json(getGameForUser(game.id, me)); }); // ── Aufgeben ────────────────────────────────────────────────────────────────── @@ -233,26 +327,17 @@ router.post('/:id/resign', authenticate, (req, res) => { const me = uid(req); 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.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 bereits beendet' }); const winner = game.owner_id === me ? game.opponent_id : game.owner_id; - // move_count bleibt wie es ist — wird für Leaderboard/Liste genutzt - db.prepare(` - UPDATE geo_games SET status='finished', winner_id=?, - updated_at=datetime('now','localtime') WHERE id=? - `).run(winner, game.id); - + db.prepare(`UPDATE geo_games SET status='finished', winner_id=?, updated_at=datetime('now','localtime') WHERE id=?`).run(winner, game.id); const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; sendPush(winner, '⬡ Hex Wars gewonnen!', `${myName} hat aufgegeben. Du gewinnst! 🎉`); - res.json({ ok: true }); }); -module.exports = router; - -// ── Spiel löschen (nur Admin, nur beendete/aufgegebene) ─────────────────────── +// ── Löschen (Admin) ─────────────────────────────────────────────────────────── router.delete('/:id', authenticate, (req, res) => { if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); @@ -261,3 +346,5 @@ router.delete('/:id', authenticate, (req, res) => { db.prepare('DELETE FROM geo_games WHERE id=?').run(req.params.id); res.json({ ok: true }); }); + +module.exports = router; diff --git a/frontend/src/tools/gebietseroberung.jsx b/frontend/src/tools/gebietseroberung.jsx index 3e9cfac..fd0605a 100644 --- a/frontend/src/tools/gebietseroberung.jsx +++ b/frontend/src/tools/gebietseroberung.jsx @@ -6,46 +6,60 @@ 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 { - const token = localStorage.getItem('sk_token'); - if (!token) return null; - return JSON.parse(atob(token.split('.')[1])).id || null; - } catch { return null; } + try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).id || null; } catch { return null; } } function getMyRole() { - try { - const token = localStorage.getItem('sk_token'); - if (!token) return null; - return JSON.parse(atob(token.split('.')[1])).role || null; - } catch { return null; } + try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role || null; } catch { return null; } } // ── Spielerklärung ──────────────────────────────────────────────────────────── function HelpModal({ onClose }) { return ( -
-
+
+
⬡ SPIELREGELN
-
-

Ziel: Am Ende mehr Felder als dein Gegner besitzen.

+
+

+ Ziel: Sammle am Ende mehr Punkte als dein Gegner. +

+

Ablauf:
- Beide starten in einer Ecke. Abwechselnd wählst du eine - graue Zelle die an dein Gebiet grenzt - (auch diagonal). Bestätige deinen Zug bevor er gespeichert wird. + Beide starten in einer Ecke. Reihum wählst du ein graues Feld das an dein Gebiet grenzt (auch diagonal) und besetzt es.

-

Taktik:
- Expand schnell — aber mauere deinen Gegner ein bevor er dich einsperrt! + +

+
FELDER & PUNKTE
+
+ Normales Feld — +1 Punkt + Goldfeld — +3 Punkte, sehr wertvoll! + 💣Sprengmine — −3 Punkte & du verlierst deinen nächsten Zug + 🪨Felsen — unbesetzbar, blockiert Wege +
+
+ +

+ 🌫️ Nebel:
+ Du siehst nur dein Gebiet und die direkt angrenzenden Felder. Was dahinter liegt bleibt verborgen — auch Minen und Goldfelder. Erst wenn du nah genug rankommst wird es aufgedeckt.

-

Ende:
- Wenn niemand mehr ziehen kann, gewinnt wer mehr Felder hat. - Du bekommst eine Pushover-Benachrichtigung wenn du dran bist. + +

+ Ende:
+ Wenn niemand mehr ziehen kann, gewinnt wer mehr Punkte hat. Tippe ein Feld an und bestätige deinen Zug — du kriegst eine Pushover-Benachrichtigung wenn du dran bist.

-
- 📱 Auf dem Handy: Pinch zum Zoomen, dann Zug antippen und bestätigen. + +
+ 📱 Auf dem Handy: Pinch zum Zoomen, dann Feld antippen und bestätigen.
@@ -74,7 +88,7 @@ function NewGameModal({ onClose, onCreated, toast }) { }; return ( -
+
NEUES SPIEL @@ -100,22 +114,15 @@ function NewGameModal({ onClose, onCreated, toast }) { function Leaderboard() { const [rows, setRows] = useState(null); useEffect(() => { api('/tools/gebietseroberung/leaderboard').then(setRows).catch(() => setRows([])); }, []); - if (rows === null) return null; + if (!rows) return null; const medals = ['🥇','🥈','🥉']; return (
TOPLISTE — SIEGE
{rows.length === 0 - ?
- Noch keine abgeschlossenen Spiele. -
- : rows.map((r, i) => ( -
+ ?
Noch keine abgeschlossenen Spiele.
+ : rows.map((r,i) => ( +
{medals[i] || `${i+1}.`} {r.username} {r.wins} @@ -127,30 +134,48 @@ function Leaderboard() { ); } -// ── Spielfeld mit Zoom + Zug-Bestätigung ────────────────────────────────────── +// ── Spielfeld ───────────────────────────────────────────────────────────────── function GameBoard({ game, myId, onMove, toast }) { - const [pending, setPending] = useState(null); // { row, col } — gewählter aber noch nicht bestätigter Zug + const [pending, setPending] = useState(null); const [moving, setMoving] = useState(false); - const containerRef = useRef(null); + 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 grid = JSON.parse(game.grid); 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 + useEffect(() => { + if (!game.last_event) return; + try { + const ev = JSON.parse(game.last_event); + if (ev.type === 'mine') setMineAlert(ev); + } 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=0&&nr=0&&nc isMyTurn && game.status === 'active' && grid[r][c] === 0 && isAdj(r, c); + + const canClick = (r, c) => { + if (!isMyTurn || game.status !== 'active') return false; + if (grid[r][c] !== 0) return false; + if (hasTerrain && terrain[r][c] === T_ROCK) return false; + if (hasTerrain && terrain[r][c] === T_FOG) return false; + return isAdj(r, c); + }; const handleCellClick = (r, c) => { if (!canClick(r, c) || moving) return; - // Gleiche Zelle nochmal → Abwählen if (pending?.row === r && pending?.col === c) { setPending(null); return; } - setPending({ row: r, col: c }); + setPending({ row:r, col:c }); }; const confirmMove = async () => { @@ -164,9 +189,24 @@ function GameBoard({ game, myId, onMove, toast }) { } finally { setMoving(false); } }; + // Punkte berechnen (clientseitig, nur aus sichtbaren Feldern) + const calcVisible = (playerId) => { + let score = 0; + for (let r=0;r v === myId).length; const oppCount = grid.flat().filter(v => v === opponent).length; - const neutral = grid.flat().filter(v => v === 0).length; + const fogCount = grid.flat().filter(v => v === 0 && hasTerrain).length; let banner = null; if (game.status === 'finished') { @@ -175,30 +215,97 @@ function GameBoard({ game, myId, onMove, toast }) { else banner = { text:'Du hast verloren.', color:OPP_COLOR }; } - // Zellgröße: Grid passt ins Viewport, aber per CSS transform zoom möglich const vw = Math.min(window.innerWidth, 600); const cellSize = Math.max(14, Math.floor((vw - 32) / GRID)); + // 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 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 (isPend) { bg = myColor; border = `2px solid ${myColor}`; } + else if (clic && !isPend) 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
-
{myCount}
-
Felder
+
{myScore}
+
{myCount} Felder
-
-
vs
-
{neutral} frei
+
+
Pkt
+
🌫️ {fogCount}
{oppName?.toUpperCase()}
-
{oppCount}
-
Felder
+
{oppScore}
+
{oppCount} Felder
+ {/* Mine-Alert */} + {mineAlert && ( +
+ 💣 + + {mineAlert.player === myId + ? 'Du hast eine Mine getroffen! −3 Punkte & Zug verloren.' + : `${oppName} hat eine Mine getroffen! 😈`} + + +
+ )} + {/* Gewinner-Banner */} {banner && (
{banner.text}
)} - {/* Zug-Status + Bestätigungs-Bar */} + {/* Zug-Status */} {game.status === 'active' && (
{!isMyTurn && ( @@ -219,7 +326,7 @@ function GameBoard({ game, myId, onMove, toast }) { )} {isMyTurn && !pending && (
- ▶ Wähle ein Feld + ▶ Wähle ein Feld — Vorsicht vor Minen! 💣
)} {isMyTurn && pending && ( @@ -229,7 +336,7 @@ function GameBoard({ game, myId, onMove, toast }) { borderRadius:8, padding:'10px 14px', }}> - ✦ Zeile {pending.row + 1}, Spalte {pending.col + 1} — Zug bestätigen? + ✦ Zeile {pending.row+1}, Spalte {pending.col+1} — Zug bestätigen?
)} - {/* Grid — overflow:auto + touch-action:pinch-zoom für Handy-Zoom */} -
-
- {grid.map((row, r) => row.map((cell, c) => { - const clic = canClick(r, c); - const isPending = pending?.row === r && pending?.col === c; - let bg = 'rgba(255,255,255,0.05)'; - if (cell === myId) bg = `${myColor}55`; - if (cell === opponent) bg = `${oppColor}55`; - if (clic) bg = `${myColor}20`; - if (isPending) bg = myColor; - return ( -
handleCellClick(r, c)} - style={{ - width:cellSize, height:cellSize, - background:bg, - borderRadius:1, - cursor: clic ? 'pointer' : 'default', - transition:'background 0.08s', - border: isPending ? `2px solid ${myColor}` : clic ? `1px solid ${myColor}44` : '1px solid transparent', - boxSizing:'border-box', - }} - /> - ); - }))} + {/* 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)))}
@@ -293,46 +370,11 @@ function GameBoard({ game, myId, onMove, toast }) { ); } -// ── Spielliste ──────────────────────────────────────────────────────────────── -function GameList({ games, myId, isAdmin, onSelect, onNew, onDelete, toast }) { - const active = games.filter(g => g.status === 'active'); - const finished = games.filter(g => g.status === 'finished' && (g.move_count || 0) >= 2); - - // Sub-Komponente AUSSERHALB von GameList definiert (weiter unten als top-level) - 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! -
- )} - - -
- ); -} - -// ── Spielzeile (top-level, nicht innerhalb GameList) ────────────────────────── -function GameRow({ g, myId, isAdmin, onSelect, onDelete, finished }) { +// ── 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 grid = JSON.parse(g.grid); - const myCount = grid.flat().filter(v => v === myId).length; - const oppCount = grid.flat().filter(v => v !== myId && v !== 0).length; + 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' }; @@ -340,18 +382,14 @@ function GameRow({ g, myId, isAdmin, onSelect, onDelete, finished }) { else result = { label:'Verloren', color:OPP_COLOR }; } return ( -
+
{result @@ -361,18 +399,41 @@ function GameRow({ g, myId, isAdmin, onSelect, onDelete, finished }) { } {isAdmin && finished && ( - + )}
); } +// ── Spielliste ──────────────────────────────────────────────────────────────── +function GameList({ games, myId, isAdmin, onSelect, onNew, onDelete }) { + 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, mobile }) { +export default function HexWars({ toast }) { const [games, setGames] = useState([]); const [activeId, setActiveId] = useState(null); const [game, setGame] = useState(null); @@ -384,10 +445,8 @@ export default function HexWars({ toast, mobile }) { const isAdmin = getMyRole() === 'admin'; const loadGames = useCallback(async () => { - try { - const gs = await api('/tools/gebietseroberung'); - setGames(gs); - } catch { toast?.('Fehler beim Laden', 'error'); } + try { setGames(await api('/tools/gebietseroberung')); } + catch { toast?.('Fehler beim Laden', 'error'); } finally { setLoading(false); } }, []); @@ -398,7 +457,7 @@ export default function HexWars({ toast, mobile }) { api(`/tools/gebietseroberung/${activeId}`).then(setGame).catch(() => {}); }, [activeId]); - // Polling wenn Gegner dran ist + // Polling wenn Gegner dran useEffect(() => { if (!activeId || !game || !myId) return; if (game.status !== 'active' || game.current_turn === myId) return; @@ -435,13 +494,10 @@ export default function HexWars({ toast, mobile }) { } catch(e) { toast?.(e.message || 'Fehler', 'error'); } }; - if (loading) return ( -
Lade…
- ); + if (loading) return
Lade…
; return (
- {/* Header — gleicher Stil wie Kanban/Whiteboard */}
{activeId && ( @@ -457,23 +513,13 @@ export default function HexWars({ toast, mobile }) {
{!activeId - ? setShowNew(true)} - onDelete={handleDelete} toast={toast} - /> + ? setShowNew(true)} onDelete={handleDelete} /> : game && myId ? :
Lade Spiel…
} - {showNew && ( - setShowNew(false)} - toast={toast} - onCreated={id => { setShowNew(false); setActiveId(id); loadGames(); }} - /> - )} + {showNew && setShowNew(false)} toast={toast} onCreated={id => { setShowNew(false); setActiveId(id); loadGames(); }} />} {showHelp && setShowHelp(false)} />}
);