feat: Hex Wars - Server-Scores, Minenexplosion, Dominanzsieg, Regel-Update
This commit is contained in:
@@ -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,26 +91,42 @@ 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;
|
||||||
@@ -140,7 +136,7 @@ function sendPush(userId, title, message) {
|
|||||||
}).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 };
|
||||||
|
|
||||||
|
// 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 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' });
|
||||||
@@ -261,37 +267,58 @@ router.post('/:id/move', authenticate, (req, res) => {
|
|||||||
|
|
||||||
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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user