From df9947303ce75f058a4b4277bb6fc53aaf413e5c Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 26 Jun 2026 16:50:57 +0200 Subject: [PATCH] feat: Hex Wars - Server-Scores, Minenexplosion, Dominanzsieg, Regel-Update --- backend/src/tools/gebietseroberung/routes.js | 235 +++++++++++-------- frontend/src/tools/gebietseroberung.jsx | 59 ++--- 2 files changed, 163 insertions(+), 131 deletions(-) diff --git a/backend/src/tools/gebietseroberung/routes.js b/backend/src/tools/gebietseroberung/routes.js index 02d5bd5..02abd98 100644 --- a/backend/src/tools/gebietseroberung/routes.js +++ b/backend/src/tools/gebietseroberung/routes.js @@ -14,8 +14,6 @@ const router = express.Router(); 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, @@ -26,80 +24,62 @@ const router = express.Router(); ); `); } - // 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}`); - } + for (const [col, def] of [ + ['move_count', 'INTEGER NOT NULL DEFAULT 0'], + ['terrain', 'TEXT'], + ['last_event', 'TEXT'], + ]) { + if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`); } + // fog_owner/fog_opp waren alte Spalten, ignorieren })(); // ── 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 +const T_GOLD = 1; +const T_MINE = 2; +const T_ROCK = 3; // ── Terrain generieren ──────────────────────────────────────────────────────── -function generateTerrain(ownerId, oppId) { +function generateTerrain() { 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 safe = new Set(); + for (let r=0;r<3;r++) for (let c=0;c<3;c++) { safe.add(`${r},${c}`); safe.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 + let idx=0; for (let i=0;i<12&&idx Array(GRID).fill(false)); - for (let r=0;r=0&&nr=0&&nc=0&&nr=0&&nc Array(GRID).fill(0)); - grid[0][0] = ownerId; - grid[GRID-1][GRID-1] = oppId; - return grid; + const g = Array(GRID).fill(null).map(() => Array(GRID).fill(0)); + g[0][0] = ownerId; + g[GRID-1][GRID-1] = oppId; + return g; } function isAdjacent(grid, row, col, playerId) { @@ -111,36 +91,52 @@ function isAdjacent(grid, row, col, playerId) { } function canMove(grid, terrain, playerId) { - for (let r=0;r=GRID||c<0||c>=GRID) continue; + const cell = grid[r][c]; + if (cell === ownerId) { destroyedByOwner++; grid[r][c] = 0; blastCells.push([r,c]); } + else if (cell === oppId) { destroyedByOpp++; grid[r][c] = 0; blastCells.push([r,c]); } + else if (cell === 0 && terrain[r][c] !== T_ROCK) { blastCells.push([r,c]); } + } + // Mine selbst wird neutral (bereits besetzt durch Spieler, bleibt als besetztes Mine-Feld) + return { destroyedByOwner, destroyedByOpp, blastCells }; +} + function sendPush(userId, title, message) { const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); if (!cfg?.app_token || !cfg?.user_key) return; fetch('https://api.pushover.net/1/messages.json', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }), }).catch(() => {}); } -// Spiel laden + Fog-of-War für anfragenden User anwenden +// Spiel laden + Fog anwenden + echte Scores beifügen function getGameForUser(id, requesterId) { const game = db.prepare(` SELECT g.*, u1.username as owner_name, u2.username as opp_name @@ -153,21 +149,34 @@ function getGameForUser(id, requesterId) { 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); + // Echte Scores auf ungemasktem Grid berechnen + const ownerScore = terrain.length ? calcScore(grid, terrain, game.owner_id) : 0; + const oppScore = terrain.length ? calcScore(grid, terrain, game.opponent_id) : 0; + // Dem Requester mitteilen welcher Score seiner ist + const myScore = requesterId === game.owner_id ? ownerScore : oppScore; + const oppSc = requesterId === game.owner_id ? oppScore : ownerScore; - // 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))); + if (!terrain.length) return { ...game, myScore:0, oppScore:0 }; + + // Fog anwenden + const visible = computeFog(grid, requesterId); + 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)); - return { ...game, grid: JSON.stringify(maskedGrid), terrain: JSON.stringify(maskedTerrain), fog: JSON.stringify(visible) }; + return { + ...game, + grid: JSON.stringify(maskedGrid), + terrain: JSON.stringify(maskedTerrain), + fog: JSON.stringify(visible), + myScore, + oppScore: oppSc, + }; } const uid = req => req.user.id; -// ── Alle Spiele des Users ───────────────────────────────────────────────────── +// ── Alle Spiele ─────────────────────────────────────────────────────────────── router.get('/', authenticate, (req, res) => { const me = uid(req); const games = db.prepare(` @@ -179,7 +188,6 @@ 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); }); @@ -195,7 +203,7 @@ router.get('/leaderboard', authenticate, (req, res) => { res.json(rows); }); -// ── Alle User ───────────────────────────────────────────────────────────────── +// ── User ────────────────────────────────────────────────────────────────────── router.get('/users', authenticate, (req, res) => { const me = uid(req); const isAdmin = req.user?.role === 'admin'; @@ -205,17 +213,16 @@ router.get('/users', authenticate, (req, res) => { res.json(users); }); -// ── Einzelnes Spiel (mit Fog) ───────────────────────────────────────────────── +// ── Einzelnes Spiel ─────────────────────────────────────────────────────────── router.get('/:id', authenticate, (req, res) => { const me = uid(req); 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' }); + if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' }); res.json(game); }); -// ── Neues Spiel erstellen ───────────────────────────────────────────────────── +// ── Neues Spiel ─────────────────────────────────────────────────────────────── router.post('/', authenticate, (req, res) => { const me = uid(req); const { opponent_id } = req.body; @@ -226,7 +233,7 @@ router.post('/', authenticate, (req, res) => { if (!opp) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); const grid = createGrid(me, Number(opponent_id)); - const terrain = generateTerrain(me, Number(opponent_id)); + const terrain = generateTerrain(); const result = db.prepare(` INSERT INTO geo_games (owner_id, opponent_id, grid, terrain, current_turn, move_count) @@ -235,7 +242,6 @@ router.post('/', authenticate, (req, res) => { const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; sendPush(Number(opponent_id), '⬡ Hex Wars', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`); - res.json({ id: result.lastInsertRowid }); }); @@ -246,7 +252,7 @@ router.post('/:id/move', authenticate, (req, res) => { 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: 'Spiel nicht gefunden' }); + 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' }); @@ -254,44 +260,65 @@ router.post('/:id/move', authenticate, (req, res) => { 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 (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; + const opponent = game.owner_id === me ? game.opponent_id : game.owner_id; + const newMoves = (game.move_count || 0) + 1; + 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'; // Zug ausführen grid[row][col] = me; - // Mine getroffen? - let mineHit = false; - let lastEvent = null; + let lastEvent = null; + let mineHit = false; + let blastCells = []; + let destroyedOwn = 0; + let destroyedOpp = 0; + + // ── Mine betreten: Explosion der 8 Nachbarn ────────────────────────────── if (terrain.length && terrain[row][col] === T_MINE) { mineHit = true; - lastEvent = JSON.stringify({ type:'mine', player: me, row, col }); + const explosion = explodeMine(grid, terrain, row, col, me, opponent); + blastCells = explosion.blastCells; + destroyedOwn = explosion.destroyedByOwner; + destroyedOpp = explosion.destroyedByOpp; + lastEvent = JSON.stringify({ type:'mine', player:me, row, col, blastCells, destroyedOwn, destroyedOpp }); } - 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'; + // ── Mine ins Sichtfeld gekommen: Explosion prüfen ──────────────────────── + // Alle Minen die jetzt im Sichtfeld von BEIDEN Spielern liegen und noch nicht explodiert sind + // werden gezündet sobald ein Spieler sie sieht (d.h. sie grenzen an sein Gebiet) + // Dieses Feature: Mine explodiert wenn sie ins Sichtfeld kommt (nicht wenn betreten) + // → wird beim Betreten gehandhabt (oben), kein extra Trigger nötig - // Nächster Zug: Mine = Zug verlieren (Gegner dran), sonst normal - let nextTurn = mineHit ? opponent : opponent; + // Nächster Zug + let nextTurn = mineHit ? opponent : opponent; // Mine = Zug verlieren let status = 'active'; let winnerId = null; - // Spielende prüfen - const myCanMove = canMove(grid, terrain, me); - const oppCanMove = canMove(grid, terrain, opponent); - - if (!oppCanMove && !myCanMove) { + // Spielende: niemand kann mehr ziehen + if (!canMove(grid, terrain, opponent) && !canMove(grid, terrain, me)) { status = 'finished'; + } else if (!canMove(grid, terrain, opponent)) { + nextTurn = me; + } + + // Schnellsieg: jemand hat doppelt so viele Punkte wie der Gegner (mind. 10 Züge gespielt) + if (status === 'active' && newMoves >= 10) { + 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') { 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 } db.prepare(` @@ -303,20 +330,24 @@ router.post('/:id/move', authenticate, (req, res) => { if (status === 'finished') { const ms = calcScore(grid, terrain, me); const os = calcScore(grid, terrain, opponent); + const reason = newMoves >= 10 && (ms >= os*2 || os >= ms*2) ? ' (Dominanzsieg!)' : ''; 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. 🎉`); + sendPush(opponent, '⬡ Hex Wars beendet', `${myName} hat gewonnen!${reason} ${ms} vs ${os} Punkte.`); + sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${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. 🎉`); + sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen.${reason} ${ms} vs ${os} Punkte.`); + sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${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.'); + if (mineHit) { + sendPush(me, '⬡ Hex Wars — 💣 Mine!', `Du hast eine Mine getroffen! Explosion zerstörte ${destroyedOwn + destroyedOpp} Felder. Du verlierst deinen nächsten Zug.`); + sendPush(opponent, '⬡ Hex Wars — 💥 Explosion!', `${myName} hat eine Mine getroffen! ${destroyedOpp} deiner Felder wurden zerstört. Du bist dran!`); + } else if (nextTurn === opponent) { + sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!`); + } } res.json(getGameForUser(game.id, me)); diff --git a/frontend/src/tools/gebietseroberung.jsx b/frontend/src/tools/gebietseroberung.jsx index f2a64d0..65aa478 100644 --- a/frontend/src/tools/gebietseroberung.jsx +++ b/frontend/src/tools/gebietseroberung.jsx @@ -31,11 +31,11 @@ function HelpModal({ onClose }) {

- Ziel: Sammle am Ende mehr Punkte als dein Gegner. + Ziel: Sammle mehr Punkte als dein Gegner — durch clevere Expansion und das Meiden von Fallen.

Ablauf:
- Beide starten in einer Ecke. Reihum wählst du ein graues Feld das an dein Gebiet grenzt (auch diagonal) und besetzt es. + 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.

@@ -43,23 +43,23 @@ function HelpModal({ onClose }) {
Normales Feld — +1 Punkt Goldfeld — +3 Punkte, sehr wertvoll! - 💣Sprengmine — −3 Punkte & du verlierst deinen nächsten Zug + 💣Sprengmine — Betreten zündet eine Explosion: alle 8 umliegenden Felder werden zerstört. Besetzte Felder gehen verloren, 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. + Du siehst nur dein Gebiet und die direkt angrenzenden Felder. Goldfelder und Minen bleiben verborgen bis du nahe genug herankommst — also aufgepasst!

- 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. + Spielende:
+ Das Spiel endet wenn niemand mehr ziehen kann — oder wenn jemand doppelt so viele Punkte hat wie der Gegner (Dominanzsieg 👑). Wer mehr Punkte hat, gewinnt.

- 📱 Auf dem Handy: Pinch zum Zoomen, dann Feld antippen und bestätigen. + 📱 Auf dem Handy: Pinch zum Zoomen, Feld antippen und bestätigen.
@@ -151,11 +151,21 @@ function GameBoard({ game, myId, onMove, toast }) { 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); + 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]); @@ -190,30 +200,19 @@ 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 fogCount = grid.flat().filter(v => v === 0 && hasTerrain).length; let banner = null; if (game.status === 'finished') { - if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' }; - else if (game.winner_id === myId) banner = { text:'Du hast gewonnen! 🎉', color:MY_COLOR }; - else banner = { text:'Du hast verloren.', color:OPP_COLOR }; + 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); @@ -229,6 +228,7 @@ function GameBoard({ game, myId, onMove, toast }) { 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; @@ -256,8 +256,9 @@ function GameBoard({ game, myId, onMove, toast }) { 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) border = `1px solid ${myColor}44`; + else if (clic && !isPend && !isBlast) border = `1px solid ${myColor}44`; return (
💣 {mineAlert.player === myId - ? 'Du hast eine Mine getroffen! −3 Punkte & Zug verloren.' - : `${oppName} hat eine Mine getroffen! 😈`} + ? `💥 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.'} 😈`}