feat(game-14-1): Implement phase 1 foundation

Implements the foundational UI and logic for the 14.1 Endless game mode, as detailed in issue #18.

- Adds a new  component to handle the specific game view.
- Introduces a modal within the game view to select the starting player.
- Replaces text input with a button grid for selecting remaining balls.
- Updates  to correctly initialize the 14.1 game state.

Closes #18
This commit is contained in:
Frank Schwenk
2025-06-20 15:35:38 +02:00
parent 875e9c8795
commit a71c65852d
4 changed files with 202 additions and 11 deletions

View File

@@ -69,22 +69,42 @@ const App = () => {
const handleCreateGame = useCallback(({ player1, player2, player3, gameType, raceTo }) => {
const newGame = {
id: Date.now(),
player1,
player2,
player3,
score1: 0,
score2: 0,
score3: 0,
gameType,
raceTo,
raceTo: parseInt(raceTo, 10),
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
updatedAt: new Date().toISOString(),
history: [],
};
if (gameType === '14/1 endlos') {
const players = [{ name: player1, score: 0, consecutiveFouls: 0 }, { name: player2, score: 0, consecutiveFouls: 0 }];
if (player3) {
players.push({ name: player3, score: 0, consecutiveFouls: 0 });
}
newGame.players = players;
newGame.currentPlayer = null; // Set to null, will be chosen in GameDetail141
newGame.ballsOnTable = 15;
} else {
newGame.player1 = player1;
newGame.player2 = player2;
newGame.score1 = 0;
newGame.score2 = 0;
if (player3) {
newGame.player3 = player3;
newGame.score3 = 0;
}
}
setGames(g => [newGame, ...g]);
return newGame.id;
}, []);
// Game update for 14.1
const handleUpdateGame = useCallback((updatedGame) => {
setGames(games => games.map(game => (game.id === currentGameId ? updatedGame : game)));
}, [currentGameId]);
// Score update
const handleUpdateScore = useCallback((player, change) => {
setGames(games => games.map(game => {
@@ -168,7 +188,7 @@ const App = () => {
setNewGameStep('gameType');
};
const handleGameTypeNext = (type) => {
setNewGameData(data => ({ ...data, gameType: type }));
setNewGameData(data => ({ ...data, gameType: type, raceTo: type === '14/1 endlos' ? '150' : '50' }));
setNewGameStep('raceTo');
};
const handleRaceToNext = (raceTo) => {
@@ -250,8 +270,9 @@ const App = () => {
<div className="screen-content">
<GameDetail
game={games.find(g => g.id === currentGameId)}
onFinishGame={handleFinishGame}
onUpdateScore={handleUpdateScore}
onUpdate={handleUpdateGame}
onFinishGame={handleFinishGame}
onBack={showGameList}
/>
</div>

View File

@@ -1,5 +1,6 @@
import { h } from 'preact';
import styles from './GameDetail.module.css';
import GameDetail141 from './GameDetail141.jsx';
/**
* Game detail view for a single game.
@@ -7,11 +8,17 @@ import styles from './GameDetail.module.css';
* @param {object} props.game
* @param {Function} props.onFinishGame
* @param {Function} props.onUpdateScore
* @param {Function} props.onUpdate
* @param {Function} props.onBack
* @returns {import('preact').VNode|null}
*/
const GameDetail = ({ game, onFinishGame, onUpdateScore, onBack }) => {
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdate, onBack }) => {
if (!game) return null;
if (game.gameType === '14/1 endlos') {
return <GameDetail141 game={game} onUpdate={onUpdate} onBack={onBack} />;
}
const isCompleted = game.status === 'completed';
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);

View File

@@ -122,4 +122,54 @@
margin: 40px 0 0 0;
width: 100%;
justify-content: center;
}
.franky .player-name {
font-weight: bold;
color: #ff8c00; /* Example color */
}
.active-player {
border: 2px solid #4caf50;
box-shadow: 0 0 10px #4caf50;
}
.turn-indicator {
margin: 20px 0;
font-size: 1.2rem;
text-align: center;
}
.potted-balls-container {
margin-top: 2rem;
padding: 1rem;
background: #2a2a2a;
border-radius: 8px;
}
.potted-balls-header {
text-align: center;
font-size: 1.1rem;
margin-bottom: 1rem;
font-weight: 600;
}
.potted-balls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 10px;
}
.potted-ball-btn {
padding: 1rem;
font-size: 1.2rem;
font-weight: bold;
border-radius: 8px;
border: 1px solid #444;
background-color: #333;
color: #fff;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
}
.potted-ball-btn:hover:not(:disabled) {
background-color: #45a049;
transform: translateY(-2px);
}
.potted-ball-btn:disabled {
background-color: #222;
color: #555;
cursor: not-allowed;
}

View File

@@ -0,0 +1,113 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import modalStyles from './PlayerSelectModal.module.css';
const StartingPlayerModal = ({ players, onSelect, onCancel }) => (
<div className={modalStyles.modalOverlay}>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
<h3>Welcher Spieler fängt an?</h3>
{/* A cancel button isn't strictly needed if a choice is mandatory */}
</div>
<div className={modalStyles.playerList}>
{players.map((player, index) => (
<button key={player.name} className={modalStyles.playerItem} onClick={() => onSelect(index)}>
{player.name}
</button>
))}
</div>
</div>
</div>
);
const GameDetail141 = ({ game, onUpdate, onBack }) => {
const handleSelectStartingPlayer = (playerIndex) => {
onUpdate({
...game,
currentPlayer: playerIndex,
});
};
// If no player is selected yet, show the modal
if (game.currentPlayer === null || game.currentPlayer === undefined) {
return <StartingPlayerModal players={game.players} onSelect={handleSelectStartingPlayer} />;
}
const currentPlayer = game.players[game.currentPlayer];
const handleTurnEnd = (remainingBalls) => {
if (remainingBalls > game.ballsOnTable) {
console.error("Cannot leave more balls than are on the table.");
return;
}
const ballsPotted = game.ballsOnTable - remainingBalls;
const newScore = currentPlayer.score + ballsPotted;
const updatedPlayers = game.players.map((p, index) =>
index === game.currentPlayer ? { ...p, score: newScore } : p
);
const nextPlayer = (game.currentPlayer + 1) % game.players.length;
onUpdate({
...game,
players: updatedPlayers,
ballsOnTable: remainingBalls,
currentPlayer: nextPlayer,
history: [...game.history, { player: currentPlayer.name, ballsPotted, newScore, ballsOnTable: remainingBalls }],
});
};
return (
<div className={styles['game-detail']}>
<div className={styles['game-title']}>
14/1 endlos | Race to {game.raceTo}
</div>
<div className={styles['scores-container']}>
{game.players.map((p, idx) => (
<div
className={`${styles['player-score']} ${idx === game.currentPlayer ? styles['active-player'] : ''}`}
key={p.name}
>
<span className={styles['player-name']}>{p.name}</span>
<span className={styles['score']}>{p.score}</span>
</div>
))}
</div>
<div className={styles['turn-indicator']}>
Aktueller Spieler: <strong>{currentPlayer.name}</strong> ({game.ballsOnTable} Bälle auf dem Tisch)
</div>
<div className={styles['potted-balls-container']}>
<p className={styles['potted-balls-header']}>Bälle am Ende der Aufnahme:</p>
<div className={styles['potted-balls-grid']}>
{Array.from({ length: 16 }, (_, i) => i).map(num => (
<button
key={num}
onClick={() => handleTurnEnd(num)}
disabled={num > game.ballsOnTable}
className={styles['potted-ball-btn']}
>
{num}
</button>
))}
</div>
</div>
{/* Placeholder for future buttons */}
<div className={styles['foul-controls']}>
<p>Fouls & Re-Racks (nächste Phase)</p>
</div>
<div className={styles['game-detail-controls']}>
<button className="btn" onClick={onBack}>Zurück zur Liste</button>
</div>
</div>
);
};
export default GameDetail141;