feat: Hex Wars - Schlangen-Mechanik, nur vom Kopf aus expandieren

This commit is contained in:
2026-06-28 13:02:55 +02:00
parent c9de0867e5
commit 93807f7672
2 changed files with 73 additions and 22 deletions

View File

@@ -28,6 +28,8 @@ const router = express.Router();
['move_count', 'INTEGER NOT NULL DEFAULT 0'], ['move_count', 'INTEGER NOT NULL DEFAULT 0'],
['terrain', 'TEXT'], ['terrain', 'TEXT'],
['last_event', 'TEXT'], ['last_event', 'TEXT'],
['owner_head', 'TEXT'],
['opp_head', 'TEXT'],
]) { ]) {
if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`); if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`);
} }
@@ -82,6 +84,12 @@ function createGrid(ownerId, oppId) {
return g; return g;
} }
// Prüft ob [row,col] an den Kopf [hr,hc] angrenzt (8 Richtungen)
function isAdjacentToHead(row, col, hr, hc) {
return Math.abs(row - hr) <= 1 && Math.abs(col - hc) <= 1 && !(row === hr && col === hc);
}
// Fallback für canMove: prüft ob Spieler noch irgendwo vom Kopf aus ziehen kann
function isAdjacent(grid, row, col, playerId) { function isAdjacent(grid, row, col, playerId) {
for (const [dr,dc] of DIRS8) { for (const [dr,dc] of DIRS8) {
const r=row+dr, c=col+dc; const r=row+dr, c=col+dc;
@@ -90,7 +98,16 @@ function isAdjacent(grid, row, col, playerId) {
return false; return false;
} }
function canMove(grid, terrain, playerId) { function canMove(grid, terrain, playerId, headRow, headCol) {
// Schlangen-Regel: nur Felder die an den Kopf grenzen sind erreichbar
if (headRow !== undefined && headCol !== undefined) {
for (const [dr,dc] of DIRS8) {
const r=headRow+dr, c=headCol+dc;
if (r>=0&&r<GRID&&c>=0&&c<GRID&&grid[r][c]===0&&terrain[r][c]!==T_ROCK) return true;
}
return false;
}
// Fallback (alte Spiele ohne head)
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; if (grid[r][c]===0 && terrain[r][c]!==T_ROCK && isAdjacent(grid,r,c,playerId)) return true;
return false; return false;
@@ -164,6 +181,10 @@ function getGameForUser(id, 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));
// Kopf des anfragenden Spielers mitliefern (für Frontend-Validierung)
const myHead = requesterId === game.owner_id ? game.owner_head : game.opp_head;
const oppHead = requesterId === game.owner_id ? game.opp_head : game.owner_head;
return { return {
...game, ...game,
grid: JSON.stringify(maskedGrid), grid: JSON.stringify(maskedGrid),
@@ -171,6 +192,8 @@ function getGameForUser(id, requesterId) {
fog: JSON.stringify(visible), fog: JSON.stringify(visible),
myScore, myScore,
oppScore: oppSc, oppScore: oppSc,
myHead,
oppHead,
}; };
} }
@@ -236,9 +259,9 @@ router.post('/', authenticate, (req, res) => {
const terrain = generateTerrain(); 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, owner_head, opp_head)
VALUES (?, ?, ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
`).run(me, Number(opponent_id), JSON.stringify(grid), JSON.stringify(terrain), me); `).run(me, Number(opponent_id), JSON.stringify(grid), JSON.stringify(terrain), me, '0,0', `${GRID-1},${GRID-1}`);
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.`);
@@ -263,7 +286,13 @@ router.post('/:id/move', authenticate, (req, res) => {
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' }); // Schlangen-Regel: Zug muss an den eigenen Kopf angrenzen
const myHeadStr = game.owner_id === me ? game.owner_head : game.opp_head;
const [myHR, myHC] = myHeadStr ? myHeadStr.split(',').map(Number) : [null, null];
if (myHR !== null && !isAdjacentToHead(row, col, myHR, myHC))
return res.status(400).json({ error: 'Nur an deinen Kopf anbauen!' });
if (myHR === null && !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;
@@ -273,6 +302,12 @@ router.post('/:id/move', authenticate, (req, res) => {
// Zug ausführen // Zug ausführen
grid[row][col] = me; grid[row][col] = me;
// Neuen Kopf setzen
let newOwnerHead = game.owner_head;
let newOppHead = game.opp_head;
if (game.owner_id === me) newOwnerHead = `${row},${col}`;
else newOppHead = `${row},${col}`;
let lastEvent = null; let lastEvent = null;
let mineHit = false; let mineHit = false;
let blastCells = []; let blastCells = [];
@@ -300,10 +335,15 @@ router.post('/:id/move', authenticate, (req, res) => {
let status = 'active'; let status = 'active';
let winnerId = null; let winnerId = null;
// Köpfe nach Zug
const oppHeadStr = game.owner_id === me ? game.opp_head : game.owner_head;
const [oppHR, oppHC] = oppHeadStr ? oppHeadStr.split(',').map(Number) : [null, null];
const myNewHR = row, myNewHC = col;
// Spielende: niemand kann mehr ziehen // Spielende: niemand kann mehr ziehen
if (!canMove(grid, terrain, opponent) && !canMove(grid, terrain, me)) { if (!canMove(grid, terrain, opponent, oppHR, oppHC) && !canMove(grid, terrain, me, myNewHR, myNewHC)) {
status = 'finished'; status = 'finished';
} else if (!canMove(grid, terrain, opponent)) { } else if (!canMove(grid, terrain, opponent, oppHR, oppHC)) {
nextTurn = me; nextTurn = me;
} }
@@ -323,8 +363,8 @@ router.post('/:id/move', authenticate, (req, res) => {
db.prepare(` db.prepare(`
UPDATE geo_games SET grid=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?, UPDATE geo_games SET grid=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?,
updated_at=datetime('now','localtime') WHERE id=? owner_head=?, opp_head=?, updated_at=datetime('now','localtime') WHERE id=?
`).run(JSON.stringify(grid), nextTurn, status, winnerId, newMoves, lastEvent, game.id); `).run(JSON.stringify(grid), nextTurn, status, winnerId, newMoves, lastEvent, newOwnerHead, newOppHead, game.id);
// Pushover // Pushover
if (status === 'finished') { if (status === 'finished') {

View File

@@ -35,7 +35,7 @@ function HelpModal({ onClose }) {
</p> </p>
<p><strong style={{ color:'#fff' }}>Ablauf:</strong><br/> <p><strong style={{ color:'#fff' }}>Ablauf:</strong><br/>
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. Beide starten in gegenüberliegenden Ecken. Du hast einen <strong style={{ color:myColor }}>Kopf</strong> dein letzter Zug, markiert mit einem leuchtenden Rahmen. Du kannst <strong>nur an den Kopf anbauen</strong> (alle 8 Richtungen). Dein Gebiet bleibt erhalten, aber du kannst nicht mehr von alten Feldern aus expandieren. Tippe ein Feld 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)' }}>
@@ -169,18 +169,27 @@ function GameBoard({ game, myId, onMove, toast }) {
} catch {} } catch {}
}, [game.last_event]); }, [game.last_event]);
const isAdj = (r, c) => DIRS8.some(([dr,dc]) => { // Schlangen-Kopf aus Server-Response
const nr=r+dr, nc=c+dc; const myHeadStr = game.myHead;
return nr>=0&&nr<GRID&&nc>=0&&nc<GRID&&grid[nr][nc]===myId; const myHead = myHeadStr ? myHeadStr.split(',').map(Number) : null;
}); const [myHR, myHC] = myHead ?? [null, null];
const isAdjacentToHead = (r, c) => {
if (myHR === null) return false;
return Math.abs(r - myHR) <= 1 && Math.abs(c - myHC) <= 1 && !(r === myHR && c === myHC);
};
const canClick = (r, c) => { const canClick = (r, c) => {
if (!isMyTurn || game.status !== 'active') return false; if (!isMyTurn || game.status !== 'active') return false;
if (grid[r][c] !== 0) return false; if (grid[r][c] !== 0) return false;
// T_ROCK kommt vom Server auch im Nebel nicht — Felsen sind immer sichtbar
// T_FOG darf canClick NICHT blocken: Felder können klickbar aber noch im Nebel sein
if (hasTerrain && terrain[r][c] === T_ROCK) return false; if (hasTerrain && terrain[r][c] === T_ROCK) return false;
return isAdj(r, c); // Schlangen-Regel: nur Felder an den Kopf
if (myHead) return isAdjacentToHead(r, c);
// Fallback für alte Spiele ohne head
return DIRS8.some(([dr,dc]) => {
const nr=r+dr, nc=c+dc;
return nr>=0&&nr<GRID&&nc>=0&&nc<GRID&&grid[nr][nc]===myId;
});
}; };
const handleCellClick = (r, c) => { const handleCellClick = (r, c) => {
@@ -243,6 +252,7 @@ function GameBoard({ game, myId, onMove, toast }) {
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 isBlast = blastHighlight.has(`${r}-${c}`);
const isHead = myHR === r && myHC === c; // eigener Kopf
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;
@@ -271,8 +281,9 @@ function GameBoard({ game, myId, onMove, toast }) {
} }
if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; } if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; }
if (isHead && !isPend) { border = `2px solid ${myColor}`; } // Kopf immer markieren
if (isPend) { bg = myColor; border = `2px solid ${myColor}`; } if (isPend) { bg = myColor; border = `2px solid ${myColor}`; }
else if (clic && !isPend && !isBlast) border = `1px solid ${myColor}44`; else if (clic && !isPend && !isBlast && !isHead) border = `1px solid ${myColor}44`;
return ( return (
<div <div
@@ -341,7 +352,7 @@ function GameBoard({ game, myId, onMove, toast }) {
)} )}
{isMyTurn && !pending && ( {isMyTurn && !pending && (
<div style={{ color:myColor, fontFamily:'monospace', fontSize:11, textAlign:'center', padding:'4px 0' }}> <div style={{ color:myColor, fontFamily:'monospace', fontSize:11, textAlign:'center', padding:'4px 0' }}>
Wähle ein Feld Vorsicht vor Minen! 💣 Baue vom Kopf aus Vorsicht vor Minen! 💣
</div> </div>
)} )}
{pending && ( {pending && (