From 93807f7672f3ea2e0ec2e9b0e9363559a69a042a Mon Sep 17 00:00:00 2001
From: Dicken
Date: Sun, 28 Jun 2026 13:02:55 +0200
Subject: [PATCH] feat: Hex Wars - Schlangen-Mechanik, nur vom Kopf aus
expandieren
---
backend/src/tools/gebietseroberung/routes.js | 64 ++++++++++++++++----
frontend/src/tools/gebietseroberung.jsx | 31 +++++++---
2 files changed, 73 insertions(+), 22 deletions(-)
diff --git a/backend/src/tools/gebietseroberung/routes.js b/backend/src/tools/gebietseroberung/routes.js
index 02abd98..79fad04 100644
--- a/backend/src/tools/gebietseroberung/routes.js
+++ b/backend/src/tools/gebietseroberung/routes.js
@@ -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=0&&c 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') {
diff --git a/frontend/src/tools/gebietseroberung.jsx b/frontend/src/tools/gebietseroberung.jsx
index aa0e8b2..e62dc13 100644
--- a/frontend/src/tools/gebietseroberung.jsx
+++ b/frontend/src/tools/gebietseroberung.jsx
@@ -35,7 +35,7 @@ function HelpModal({ onClose }) {
Ablauf:
- 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 Kopf — dein letzter Zug, markiert mit einem leuchtenden Rahmen. Du kannst nur an den Kopf anbauen (alle 8 Richtungen). Dein Gebiet bleibt erhalten, aber du kannst nicht mehr von alten Feldern aus expandieren. Tippe ein Feld an, bestätige — fertig.
@@ -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
=0&&nc {
+ 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=0&&nc {
@@ -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 (
- ▶ Wähle ein Feld — Vorsicht vor Minen! 💣
+ ▶ Baue vom Kopf aus — Vorsicht vor Minen! 💣
)}
{pending && (