feat: Hex Wars - Schlangen-Mechanik, nur vom Kopf aus expandieren
This commit is contained in:
@@ -25,9 +25,11 @@ const router = express.Router();
|
||||
`);
|
||||
}
|
||||
for (const [col, def] of [
|
||||
['move_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['terrain', 'TEXT'],
|
||||
['last_event', 'TEXT'],
|
||||
['move_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['terrain', 'TEXT'],
|
||||
['last_event', 'TEXT'],
|
||||
['owner_head', 'TEXT'],
|
||||
['opp_head', 'TEXT'],
|
||||
]) {
|
||||
if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`);
|
||||
}
|
||||
@@ -82,6 +84,12 @@ function createGrid(ownerId, oppId) {
|
||||
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) {
|
||||
for (const [dr,dc] of DIRS8) {
|
||||
const r=row+dr, c=col+dc;
|
||||
@@ -90,7 +98,16 @@ function isAdjacent(grid, row, col, playerId) {
|
||||
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++)
|
||||
if (grid[r][c]===0 && terrain[r][c]!==T_ROCK && isAdjacent(grid,r,c,playerId)) return true;
|
||||
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 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 {
|
||||
...game,
|
||||
grid: JSON.stringify(maskedGrid),
|
||||
@@ -171,6 +192,8 @@ function getGameForUser(id, requesterId) {
|
||||
fog: JSON.stringify(visible),
|
||||
myScore,
|
||||
oppScore: oppSc,
|
||||
myHead,
|
||||
oppHead,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,9 +259,9 @@ router.post('/', authenticate, (req, res) => {
|
||||
const terrain = generateTerrain();
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO geo_games (owner_id, opponent_id, grid, terrain, current_turn, move_count)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
`).run(me, Number(opponent_id), JSON.stringify(grid), JSON.stringify(terrain), me);
|
||||
INSERT INTO geo_games (owner_id, opponent_id, grid, terrain, current_turn, move_count, owner_head, opp_head)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
|
||||
`).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';
|
||||
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 (grid[row][col] !== 0) return res.status(400).json({ error: 'Zelle belegt' });
|
||||
if (terrain.length && terrain[row][col] === T_ROCK) return res.status(400).json({ error: 'Felsen — nicht betretbar' });
|
||||
if (!isAdjacent(grid, row, col, me)) return res.status(400).json({ error: 'Nicht angrenzend' });
|
||||
// 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 newMoves = (game.move_count || 0) + 1;
|
||||
@@ -273,6 +302,12 @@ router.post('/:id/move', authenticate, (req, res) => {
|
||||
// Zug ausführen
|
||||
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 mineHit = false;
|
||||
let blastCells = [];
|
||||
@@ -300,10 +335,15 @@ router.post('/:id/move', authenticate, (req, res) => {
|
||||
let status = 'active';
|
||||
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
|
||||
if (!canMove(grid, terrain, opponent) && !canMove(grid, terrain, me)) {
|
||||
if (!canMove(grid, terrain, opponent, oppHR, oppHC) && !canMove(grid, terrain, me, myNewHR, myNewHC)) {
|
||||
status = 'finished';
|
||||
} else if (!canMove(grid, terrain, opponent)) {
|
||||
} else if (!canMove(grid, terrain, opponent, oppHR, oppHC)) {
|
||||
nextTurn = me;
|
||||
}
|
||||
|
||||
@@ -323,8 +363,8 @@ router.post('/:id/move', authenticate, (req, res) => {
|
||||
|
||||
db.prepare(`
|
||||
UPDATE geo_games SET grid=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?,
|
||||
updated_at=datetime('now','localtime') WHERE id=?
|
||||
`).run(JSON.stringify(grid), nextTurn, status, winnerId, newMoves, lastEvent, game.id);
|
||||
owner_head=?, opp_head=?, updated_at=datetime('now','localtime') WHERE id=?
|
||||
`).run(JSON.stringify(grid), nextTurn, status, winnerId, newMoves, lastEvent, newOwnerHead, newOppHead, game.id);
|
||||
|
||||
// Pushover
|
||||
if (status === 'finished') {
|
||||
|
||||
@@ -35,7 +35,7 @@ function HelpModal({ onClose }) {
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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 {}
|
||||
}, [game.last_event]);
|
||||
|
||||
const isAdj = (r, c) => DIRS8.some(([dr,dc]) => {
|
||||
const nr=r+dr, nc=c+dc;
|
||||
return nr>=0&&nr<GRID&&nc>=0&&nc<GRID&&grid[nr][nc]===myId;
|
||||
});
|
||||
// Schlangen-Kopf aus Server-Response
|
||||
const myHeadStr = game.myHead;
|
||||
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) => {
|
||||
if (!isMyTurn || game.status !== 'active') 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;
|
||||
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) => {
|
||||
@@ -243,6 +252,7 @@ function GameBoard({ game, myId, onMove, toast }) {
|
||||
const clic = canClick(r, c);
|
||||
const isPend = pending?.row === r && pending?.col === c;
|
||||
const isBlast = blastHighlight.has(`${r}-${c}`);
|
||||
const isHead = myHR === r && myHC === c; // eigener Kopf
|
||||
const isFog = t === T_FOG;
|
||||
const isRock = t === T_ROCK;
|
||||
const isGold = t === T_GOLD;
|
||||
@@ -271,8 +281,9 @@ function GameBoard({ game, myId, onMove, toast }) {
|
||||
}
|
||||
|
||||
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}`; }
|
||||
else if (clic && !isPend && !isBlast) border = `1px solid ${myColor}44`;
|
||||
else if (clic && !isPend && !isBlast && !isHead) border = `1px solid ${myColor}44`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -341,7 +352,7 @@ function GameBoard({ game, myId, onMove, toast }) {
|
||||
)}
|
||||
{isMyTurn && !pending && (
|
||||
<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>
|
||||
)}
|
||||
{pending && (
|
||||
|
||||
Reference in New Issue
Block a user