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, opponent_id INTEGER NOT NULL,
grid TEXT NOT NULL, grid TEXT NOT NULL,
terrain TEXT NOT NULL DEFAULT '[]', terrain TEXT NOT NULL DEFAULT '[]',
fog_owner TEXT NOT NULL DEFAULT '[]',
fog_opp TEXT NOT NULL DEFAULT '[]',
current_turn INTEGER NOT NULL, current_turn INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
winner_id INTEGER, winner_id INTEGER,
@@ -26,62 +24,45 @@ const router = express.Router();
); );
`); `);
} }
// Spalten nachrüsten falls Tabelle schon existiert for (const [col, def] of [
for (const col of ['move_count','terrain','fog_owner','fog_opp','last_event']) { ['move_count', 'INTEGER NOT NULL DEFAULT 0'],
if (!cols.includes(col)) { ['terrain', 'TEXT'],
const def = col === 'move_count' ? 'INTEGER NOT NULL DEFAULT 0' : 'TEXT'; ['last_event', 'TEXT'],
db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`); ]) {
} if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`);
} }
// fog_owner/fog_opp waren alte Spalten, ignorieren
})(); })();
// ── Konstanten ──────────────────────────────────────────────────────────────── // ── Konstanten ────────────────────────────────────────────────────────────────
const GRID = 20; const GRID = 20;
const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]]; 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_NORMAL = 0;
const T_GOLD = 1; // +3 Punkte const T_GOLD = 1;
const T_MINE = 2; // -3 Punkte + Zug verlieren const T_MINE = 2;
const T_ROCK = 3; // unbesetzbar const T_ROCK = 3;
// ── Terrain generieren ──────────────────────────────────────────────────────── // ── Terrain generieren ────────────────────────────────────────────────────────
function generateTerrain(ownerId, oppId) { function generateTerrain() {
const t = Array(GRID).fill(null).map(() => Array(GRID).fill(T_NORMAL)); const t = Array(GRID).fill(null).map(() => Array(GRID).fill(T_NORMAL));
const safe = new Set();
// Startecken und ihre direkten Nachbarn freihalten 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 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 = []; 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]); for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) if (!safe.has(`${r},${c}`)) cells.push([r,c]);
// Zufällig mischen
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]]; } 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; 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; 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; 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; for (let i=0;i<18&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_ROCK;
return t; return t;
} }
// ── Fog of War: sichtbare Zellen für einen Spieler berechnen ───────────────── // ── Fog of War ────────────────────────────────────────────────────────────────
function computeFog(grid, terrain, playerId) { function computeFog(grid, playerId) {
// Sichtbar: alle eigenen Zellen + alle 8 Nachbarn davon
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 r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
for (let c=0;c<GRID;c++) {
if (grid[r][c] === playerId) { if (grid[r][c] === playerId) {
visible[r][c] = true; visible[r][c] = true;
for (const [dr,dc] of DIRS8) { for (const [dr,dc] of DIRS8) {
@@ -90,16 +71,15 @@ function computeFog(grid, terrain, playerId) {
} }
} }
} }
}
return visible; return visible;
} }
// ── Grid-Hilfsfunktionen ────────────────────────────────────────────────────── // ── Hilfsfunktionen ───────────────────────────────────────────────────────────
function createGrid(ownerId, oppId) { function createGrid(ownerId, oppId) {
const grid = Array(GRID).fill(null).map(() => Array(GRID).fill(0)); const g = Array(GRID).fill(null).map(() => Array(GRID).fill(0));
grid[0][0] = ownerId; g[0][0] = ownerId;
grid[GRID-1][GRID-1] = oppId; g[GRID-1][GRID-1] = oppId;
return grid; return g;
} }
function isAdjacent(grid, row, col, playerId) { function isAdjacent(grid, row, col, playerId) {
@@ -111,36 +91,52 @@ function isAdjacent(grid, row, col, playerId) {
} }
function canMove(grid, terrain, playerId) { function canMove(grid, terrain, playerId) {
for (let r=0;r<GRID;r++) for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++)
for (let c=0;c<GRID;c++)
if (grid[r][c]===0 && terrain[r][c]!==T_ROCK && isAdjacent(grid,r,c,playerId)) return true; if (grid[r][c]===0 && terrain[r][c]!==T_ROCK && isAdjacent(grid,r,c,playerId)) return true;
return false; 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) { function calcScore(grid, terrain, playerId) {
let score = 0; let score = 0;
for (let r=0;r<GRID;r++) { for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
for (let c=0;c<GRID;c++) {
if (grid[r][c] !== playerId) continue; if (grid[r][c] !== playerId) continue;
if (terrain[r][c] === T_GOLD) score += 3; if (terrain[r][c] === T_GOLD) score += 3;
else if (terrain[r][c] === T_MINE) score -= 3; else if (terrain[r][c] === T_MINE) score -= 3;
else score += 1; else score += 1;
} }
}
return Math.max(0, score); 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) { function sendPush(userId, title, message) {
const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); 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; if (!cfg?.app_token || !cfg?.user_key) return;
fetch('https://api.pushover.net/1/messages.json', { 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 }), body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }),
}).catch(() => {}); }).catch(() => {});
} }
// Spiel laden + Fog-of-War für anfragenden User anwenden // Spiel laden + Fog anwenden + echte Scores beifügen
function getGameForUser(id, requesterId) { function getGameForUser(id, requesterId) {
const game = db.prepare(` const game = db.prepare(`
SELECT g.*, u1.username as owner_name, u2.username as opp_name 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 grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]'); const terrain = JSON.parse(game.terrain || '[]');
if (!terrain.length) return game; // Migration: altes Spiel ohne Terrain
// Fog berechnen // Echte Scores auf ungemasktem Grid berechnen
const visible = computeFog(grid, terrain, requesterId); 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 };
const maskedGrid = grid.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : (cell === requesterId ? cell : 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)); 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; const uid = req => req.user.id;
// ── Alle Spiele des Users ───────────────────────────────────────────────────── // ── Alle Spiele ───────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => { router.get('/', authenticate, (req, res) => {
const me = uid(req); const me = uid(req);
const games = db.prepare(` const games = db.prepare(`
@@ -179,7 +188,6 @@ router.get('/', authenticate, (req, res) => {
AND NOT (g.status='finished' AND g.move_count < 2) AND NOT (g.status='finished' AND g.move_count < 2)
ORDER BY g.updated_at DESC ORDER BY g.updated_at DESC
`).all(me, me); `).all(me, me);
// Für Listenansicht kein Fog nötig — nur eigene Felder zählen
res.json(games); res.json(games);
}); });
@@ -195,7 +203,7 @@ router.get('/leaderboard', authenticate, (req, res) => {
res.json(rows); res.json(rows);
}); });
// ── Alle User ───────────────────────────────────────────────────────────────── // ── User ──────────────────────────────────────────────────────────────────────
router.get('/users', authenticate, (req, res) => { router.get('/users', authenticate, (req, res) => {
const me = uid(req); const me = uid(req);
const isAdmin = req.user?.role === 'admin'; const isAdmin = req.user?.role === 'admin';
@@ -205,17 +213,16 @@ router.get('/users', authenticate, (req, res) => {
res.json(users); res.json(users);
}); });
// ── Einzelnes Spiel (mit Fog) ───────────────────────────────────────────────── // ── Einzelnes Spiel ───────────────────────────────────────────────────────────
router.get('/:id', authenticate, (req, res) => { router.get('/:id', authenticate, (req, res) => {
const me = uid(req); const me = uid(req);
const game = getGameForUser(req.params.id, me); const game = getGameForUser(req.params.id, me);
if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
if (game.owner_id !== me && game.opponent_id !== me) if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
return res.status(403).json({ error: 'Kein Zugriff' });
res.json(game); res.json(game);
}); });
// ── Neues Spiel erstellen ───────────────────────────────────────────────────── // ── Neues Spiel ───────────────────────────────────────────────────────────────
router.post('/', authenticate, (req, res) => { router.post('/', authenticate, (req, res) => {
const me = uid(req); const me = uid(req);
const { opponent_id } = req.body; 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' }); if (!opp) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
const grid = createGrid(me, Number(opponent_id)); const grid = createGrid(me, Number(opponent_id));
const terrain = generateTerrain(me, Number(opponent_id)); const terrain = generateTerrain();
const result = db.prepare(` const result = db.prepare(`
INSERT INTO geo_games (owner_id, opponent_id, grid, terrain, current_turn, move_count) 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'; 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.`); sendPush(Number(opponent_id), '⬡ Hex Wars', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`);
res.json({ id: result.lastInsertRowid }); 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' }); 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); 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.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.status !== 'active') return res.status(400).json({ error: 'Spiel beendet' });
if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' }); 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 grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]'); 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 (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 (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' }); 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 opponent = game.owner_id === me ? game.opponent_id : game.owner_id;
const newMoves = (game.move_count || 0) + 1; 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 // Zug ausführen
grid[row][col] = me; 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) { if (terrain.length && terrain[row][col] === T_MINE) {
mineHit = true; 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'; // ── Mine ins Sichtfeld gekommen: Explosion prüfen ────────────────────────
const oppName = db.prepare('SELECT username FROM users WHERE id=?').get(opponent)?.username || 'Jemand'; // 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 // Nächster Zug
let nextTurn = mineHit ? opponent : opponent; let nextTurn = mineHit ? opponent : opponent; // Mine = Zug verlieren
let status = 'active'; let status = 'active';
let winnerId = null; let winnerId = null;
// Spielende prüfen // Spielende: niemand kann mehr ziehen
const myCanMove = canMove(grid, terrain, me); if (!canMove(grid, terrain, opponent) && !canMove(grid, terrain, me)) {
const oppCanMove = canMove(grid, terrain, opponent);
if (!oppCanMove && !myCanMove) {
status = 'finished'; 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 ms = calcScore(grid, terrain, me);
const os = calcScore(grid, terrain, opponent); const os = calcScore(grid, terrain, opponent);
winnerId = ms > os ? me : (os > ms ? opponent : null); winnerId = ms > os ? me : (os > ms ? opponent : null);
} else if (!oppCanMove) {
nextTurn = me; // Gegner überspringen
} }
db.prepare(` db.prepare(`
@@ -303,20 +330,24 @@ router.post('/:id/move', authenticate, (req, res) => {
if (status === 'finished') { if (status === 'finished') {
const ms = calcScore(grid, terrain, me); const ms = calcScore(grid, terrain, me);
const os = calcScore(grid, terrain, opponent); const os = calcScore(grid, terrain, opponent);
const reason = newMoves >= 10 && (ms >= os*2 || os >= ms*2) ? ' (Dominanzsieg!)' : '';
if (winnerId === me) { if (winnerId === me) {
sendPush(opponent, '⬡ Hex Wars beendet', `${myName} hat 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! ${ms} vs ${os} Punkte. 🎉`); sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${ms} vs ${os} Punkte. 🎉`);
} else if (winnerId === opponent) { } else if (winnerId === opponent) {
sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen. ${ms} vs ${os} Punkte.`); sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen.${reason} ${ms} vs ${os} Punkte.`);
sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen! ${os} vs ${ms} Punkte. 🎉`); sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${os} vs ${ms} Punkte. 🎉`);
} else { } else {
sendPush(me, '⬡ Hex Wars — Unentschieden!', `${ms} vs ${os} Punkte.`); sendPush(me, '⬡ Hex Wars — Unentschieden!', `${ms} vs ${os} Punkte.`);
sendPush(opponent, '⬡ Hex Wars — Unentschieden!', `${os} vs ${ms} Punkte.`); sendPush(opponent, '⬡ Hex Wars — Unentschieden!', `${os} vs ${ms} Punkte.`);
} }
} else { } else {
const mineMsg = mineHit ? ' 💥 MINE! Du verlierst deinen nächsten Zug!' : ''; if (mineHit) {
if (nextTurn === opponent) sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!${mineMsg}`); sendPush(me, '⬡ Hex Wars — 💣 Mine!', `Du hast eine Mine getroffen! Explosion zerstörte ${destroyedOwn + destroyedOpp} Felder. Du verlierst deinen nächsten Zug.`);
if (mineHit) sendPush(me, '⬡ Hex Wars — 💥 Mine!', 'Du hast eine Mine getroffen! Dein nächster Zug gehört dem Gegner.'); 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)); res.json(getGameForUser(game.id, me));

View File

@@ -31,11 +31,11 @@ function HelpModal({ onClose }) {
</div> </div>
<div style={{ color:'rgba(255,255,255,0.75)', fontSize:13, lineHeight:1.9, fontFamily:'monospace' }}> <div style={{ color:'rgba(255,255,255,0.75)', fontSize:13, lineHeight:1.9, fontFamily:'monospace' }}>
<p style={{ marginTop:0 }}> <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>
<p><strong style={{ color:'#fff' }}>Ablauf:</strong><br/> <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> </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)' }}> <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' }}> <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:'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:'#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> <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>
</div> </div>
<p> <p>
<strong style={{ color:'#fff' }}>🌫 Nebel:</strong><br/> <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>
<p> <p>
<strong style={{ color:'#fff' }}>Ende:</strong><br/> <strong style={{ color:'#fff' }}>Spielende:</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. 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> </p>
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'10px 14px', border:'1px solid rgba(255,255,255,0.08)' }}> <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> </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; const oppName = game.owner_id === myId ? game.opp_name : game.owner_name;
// Mine-Event anzeigen wenn frisch // Mine-Event anzeigen wenn frisch
const [blastHighlight, setBlastHighlight] = useState(new Set());
useEffect(() => { useEffect(() => {
if (!game.last_event) return; if (!game.last_event) return;
try { try {
const ev = JSON.parse(game.last_event); 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 {} } catch {}
}, [game.last_event]); }, [game.last_event]);
@@ -190,30 +200,19 @@ function GameBoard({ game, myId, onMove, toast }) {
} finally { setMoving(false); } } finally { setMoving(false); }
}; };
// Punkte berechnen (clientseitig, nur aus sichtbaren Feldern) // Scores kommen vom Server (auf ungemasktem Grid berechnet — korrekt für beide Spieler)
const calcVisible = (playerId) => { const myScore = game.myScore ?? 0;
let score = 0; const oppScore = game.oppScore ?? 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);
const myCount = grid.flat().filter(v => v === myId).length; const myCount = grid.flat().filter(v => v === myId).length;
const oppCount = grid.flat().filter(v => v === opponent).length; const oppCount = grid.flat().filter(v => v === opponent).length;
const fogCount = grid.flat().filter(v => v === 0 && hasTerrain).length; const fogCount = grid.flat().filter(v => v === 0 && hasTerrain).length;
let banner = null; let banner = null;
if (game.status === 'finished') { if (game.status === 'finished') {
const isDominance = myScore >= oppScore * 2 || oppScore >= myScore * 2;
if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' }; if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' };
else if (game.winner_id === myId) banner = { text:'Du hast gewonnen! 🎉', color:MY_COLOR }; else if (game.winner_id === myId) banner = { text: isDominance ? 'Dominanzsieg! 👑🎉' : 'Du hast gewonnen! 🎉', color:MY_COLOR };
else banner = { text:'Du hast verloren.', color:OPP_COLOR }; else banner = { text: isDominance ? 'Dominanzniederlage 💀' : 'Du hast verloren.', color:OPP_COLOR };
} }
const vw = Math.min(window.innerWidth, 600); 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 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 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;
@@ -256,8 +256,9 @@ function GameBoard({ game, myId, onMove, toast }) {
else if (clic) bg = `${myColor}22`; else if (clic) bg = `${myColor}22`;
} }
if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; }
if (isPend) { bg = myColor; border = `2px solid ${myColor}`; } 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 ( return (
<div <div
@@ -299,8 +300,8 @@ function GameBoard({ game, myId, onMove, toast }) {
<span style={{ fontSize:20 }}>💣</span> <span style={{ fontSize:20 }}>💣</span>
<span style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:12, flex:1 }}> <span style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:12, flex:1 }}>
{mineAlert.player === myId {mineAlert.player === myId
? 'Du hast eine Mine getroffen! 3 Punkte & Zug verloren.' ? `💥 Mine! ${mineAlert.destroyedOwn ? mineAlert.destroyedOwn + ' deiner Felder zerstört. ' : ''}Zug verloren.`
: `${oppName} hat eine Mine getroffen! 😈`} : `💥 ${oppName} trat auf eine Mine! ${mineAlert.destroyedOpp ? mineAlert.destroyedOpp + ' deiner Felder zerstört!' : 'Kein Schaden.'} 😈`}
</span> </span>
<button onClick={() => setMineAlert(null)} style={{ ...S.btn('#666666', true), padding:'2px 8px' }}></button> <button onClick={() => setMineAlert(null)} style={{ ...S.btn('#666666', true), padding:'2px 8px' }}></button>
</div> </div>