feat: Hex Wars - Server-Scores, Minenexplosion, Dominanzsieg, Regel-Update

This commit is contained in:
2026-06-26 16:50:57 +02:00
parent afd2aa6029
commit df9947303c
2 changed files with 163 additions and 131 deletions

View File

@@ -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,62 +24,45 @@ 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;r<GRID;r++) for (let c=0;c<GRID;c++) if (!safeZone.has(`${r},${c}`)) cells.push([r,c]);
// Zufällig mischen
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) if (!safe.has(`${r},${c}`)) cells.push([r,c]);
for (let i=cells.length-1;i>0;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<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_GOLD;
// 10 Minen
for (let i=0;i<10&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_MINE;
// 18 Felsen
for (let i=0;i<18&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_ROCK;
return t;
}
// ── Fog of War: sichtbare Zellen für einen Spieler berechnen ─────────────────
function computeFog(grid, terrain, playerId) {
// Sichtbar: alle eigenen Zellen + alle 8 Nachbarn davon
// ── Fog of War ────────────────────────────────────────────────────────────────
function computeFog(grid, playerId) {
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) {
visible[r][c] = true;
for (const [dr,dc] of DIRS8) {
@@ -90,16 +71,15 @@ function computeFog(grid, terrain, playerId) {
}
}
}
}
return visible;
}
// ── Grid-Hilfsfunktionen ──────────────────────────────────────────────────────
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
function createGrid(ownerId, oppId) {
const grid = Array(GRID).fill(null).map(() => 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,26 +91,42 @@ function isAdjacent(grid, row, col, playerId) {
}
function canMove(grid, terrain, playerId) {
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]===0 && terrain[r][c]!==T_ROCK && isAdjacent(grid,r,c,playerId)) return true;
return false;
}
// Punkte zählen: normale Felder +1, Gold +3, Mine -3
// Score auf dem vollständigen (ungemaskten) Grid berechnen
function calcScore(grid, terrain, playerId) {
let score = 0;
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) continue;
if (terrain[r][c] === T_GOLD) score += 3;
else if (terrain[r][c] === T_MINE) score -= 3;
else score += 1;
}
}
return Math.max(0, score);
}
// ── Minenexplosion: alle 8 Nachbarn der Mine werden zerstört ─────────────────
// Gibt zurück: { grid, destroyedByOwner, destroyedByOpp, blastCells }
function explodeMine(grid, terrain, row, col, ownerId, oppId) {
let destroyedByOwner = 0;
let destroyedByOpp = 0;
const blastCells = [];
for (const [dr,dc] of DIRS8) {
const r=row+dr, c=col+dc;
if (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;
@@ -140,7 +136,7 @@ function sendPush(userId, title, message) {
}).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
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' });
@@ -261,37 +267,58 @@ router.post('/:id/move', authenticate, (req, res) => {
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 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));

View File

@@ -31,11 +31,11 @@ function HelpModal({ onClose }) {
</div>
<div style={{ color:'rgba(255,255,255,0.75)', fontSize:13, lineHeight:1.9, fontFamily:'monospace' }}>
<p style={{ marginTop:0 }}>
<strong style={{ color:MY_COLOR }}>Ziel:</strong> Sammle am Ende mehr <strong>Punkte</strong> als dein Gegner.
<strong style={{ color:MY_COLOR }}>Ziel:</strong> Sammle mehr <strong>Punkte</strong> als dein Gegner durch clevere Expansion und das Meiden von Fallen.
</p>
<p><strong style={{ color:'#fff' }}>Ablauf:</strong><br/>
Beide starten in einer Ecke. Reihum wählst du ein <strong>graues Feld</strong> 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.
</p>
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'12px 14px', marginBottom:12, border:'1px solid rgba(255,255,255,0.08)' }}>
@@ -43,23 +43,23 @@ function HelpModal({ onClose }) {
<div style={{ display:'grid', gridTemplateColumns:'28px 1fr', gap:'6px 10px', alignItems:'center' }}>
<span style={{ fontSize:16, textAlign:'center' }}></span><span><strong style={{ color:'rgba(255,255,255,0.9)' }}>Normales Feld</strong> +1 Punkt</span>
<span style={{ fontSize:16, textAlign:'center' }}></span><span><strong style={{ color:'#ffe66d' }}>Goldfeld</strong> +3 Punkte, sehr wertvoll!</span>
<span style={{ fontSize:16, textAlign:'center' }}>💣</span><span><strong style={{ color:'#ff6b9d' }}>Sprengmine</strong> 3 Punkte & du verlierst deinen nächsten Zug</span>
<span style={{ fontSize:16, textAlign:'center' }}>💣</span><span><strong style={{ color:'#ff6b9d' }}>Sprengmine</strong> Betreten zündet eine Explosion: alle 8 umliegenden Felder werden zerstört. Besetzte Felder gehen verloren, du verlierst deinen nächsten Zug.</span>
<span style={{ fontSize:16, textAlign:'center' }}>🪨</span><span><strong style={{ color:'rgba(255,255,255,0.4)' }}>Felsen</strong> unbesetzbar, blockiert Wege</span>
</div>
</div>
<p>
<strong style={{ color:'#fff' }}>🌫 Nebel:</strong><br/>
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!
</p>
<p>
<strong style={{ color:'#fff' }}>Ende:</strong><br/>
Wenn niemand mehr ziehen kann, gewinnt wer mehr Punkte hat. Tippe ein Feld an und bestätige deinen Zug du kriegst eine <strong>Pushover-Benachrichtigung</strong> wenn du dran bist.
<strong style={{ color:'#fff' }}>Spielende:</strong><br/>
Das Spiel endet wenn niemand mehr ziehen kann oder wenn jemand <strong>doppelt so viele Punkte</strong> hat wie der Gegner (Dominanzsieg 👑). Wer mehr Punkte hat, gewinnt.
</p>
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'10px 14px', border:'1px solid rgba(255,255,255,0.08)' }}>
📱 <em>Auf dem Handy: Pinch zum Zoomen, dann Feld antippen und bestätigen.</em>
📱 <em>Auf dem Handy: Pinch zum Zoomen, Feld antippen und bestätigen.</em>
</div>
</div>
</div>
@@ -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<GRID;r++) for (let c=0;c<GRID;c++) {
if (grid[r][c] !== playerId) continue;
const t = hasTerrain ? terrain[r][c] : T_NORMAL;
if (t === T_GOLD) score += 3;
else if (t === T_MINE) score -= 3;
else score += 1;
}
return Math.max(0, score);
};
const myScore = calcVisible(myId);
const oppScore = calcVisible(opponent);
// Scores kommen vom Server (auf ungemasktem Grid berechnet — korrekt für beide Spieler)
const myScore = game.myScore ?? 0;
const oppScore = game.oppScore ?? 0;
const myCount = grid.flat().filter(v => 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') {
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:'Du hast gewonnen! 🎉', color:MY_COLOR };
else banner = { text:'Du hast verloren.', color:OPP_COLOR };
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 (
<div
@@ -299,8 +300,8 @@ function GameBoard({ game, myId, onMove, toast }) {
<span style={{ fontSize:20 }}>💣</span>
<span style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:12, flex:1 }}>
{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.'} 😈`}
</span>
<button onClick={() => setMineAlert(null)} style={{ ...S.btn('#666666', true), padding:'2px 8px' }}></button>
</div>