Ajoute les jeux "6 qui prend !" et "Moustache"
Build and Publish Docker Image / build-and-push-image (push) Failing after 42s
Build and Publish Docker Image / build-and-push-image (push) Failing after 42s
- 6 qui prend ! : 2-10 joueurs, score négatif, fin dès qu'un joueur atteint 66 points, simple addition - Moustache : 3-6 joueurs, le plus de points gagne Moustache introduit une nouvelle mécanique dans le moteur : les équipes étant retirées au sort à chaque manche, on note seulement l'équipe gagnante des 4 premières manches (GameConfig.teams + teamRounds, Round.winningTeamId), puis la dernière manche sert au décompte final des points de chaque joueur. - PlayGame : sélecteur d'équipe (pas de saisie de score), validation bloquée sans choix, compteur de manches gagnées par équipe - GameOver : les manches d'équipe s'affichent avec un badge coloré au lieu d'une rangée de zéros Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -8,7 +8,9 @@ import { azulConfig } from "./azul/config";
|
||||
import { caboConfig } from "./cabo/config";
|
||||
import { chouineursConfig } from "./chouineurs/config";
|
||||
import { harmonieConfig } from "./harmonie/config";
|
||||
import { moustacheConfig } from "./moustache/config";
|
||||
import { odinCardsConfig } from "./odin_cards/config";
|
||||
import { sixQuiPrendConfig } from "./six_qui_prend/config";
|
||||
import { skyjoConfig } from "./skyjo/config";
|
||||
|
||||
// Built-in games (compiled into the app).
|
||||
@@ -19,6 +21,8 @@ export const games: GameConfig[] = [
|
||||
harmonieConfig,
|
||||
odinCardsConfig,
|
||||
chouineursConfig,
|
||||
sixQuiPrendConfig,
|
||||
moustacheConfig,
|
||||
];
|
||||
|
||||
// Imported games for the active profile, kept in sync in-memory so the
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GameConfig } from "../../types";
|
||||
|
||||
// Les équipes sont retirées au sort à chaque manche : on ne note donc que
|
||||
// l'équipe gagnante des 4 manches, puis chacun révèle ses points au décompte
|
||||
// final (5e et dernière manche). Le plus de points l'emporte.
|
||||
export const moustacheConfig: GameConfig = {
|
||||
imagePath: "/games-assets/moustache-logo.webp",
|
||||
bannerPath: "/games-assets/moustache-banner.webp",
|
||||
id: "moustache",
|
||||
name: "Moustache",
|
||||
minPlayers: 3,
|
||||
maxPlayers: 6,
|
||||
scoreType: "positive",
|
||||
teamRounds: 4,
|
||||
fixedRounds: 5, // 4 manches en équipes + le décompte final
|
||||
teams: [
|
||||
{ id: "bleu", label: "Équipe bleue", color: "#2563eb" },
|
||||
{ id: "rouge", label: "Équipe rouge", color: "#dc2626" },
|
||||
],
|
||||
description:
|
||||
"Les équipes changent à chaque manche : notez l'équipe gagnante des 4 manches, puis chacun révèle ses points. Le plus de points gagne.",
|
||||
icon: "Drama",
|
||||
colors: {
|
||||
// Le rose de la moustache du morse : distinctif, et sans confusion
|
||||
// possible avec le bleu / rouge des équipes.
|
||||
primary: "#e5709b",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { GameConfig } from "../../types";
|
||||
|
||||
export const sixQuiPrendConfig: GameConfig = {
|
||||
imagePath: "/games-assets/six-qui-prend-logo.webp",
|
||||
bannerPath: "/games-assets/six-qui-prend-banner.webp",
|
||||
id: "six_qui_prend",
|
||||
name: "6 qui prend !",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 10,
|
||||
scoreType: "negative",
|
||||
targetScore: 66,
|
||||
targetScoreCondition: "reaches", // la partie s'arrête dès qu'un joueur atteint 66
|
||||
description:
|
||||
"Évitez de ramasser les têtes de bœuf ! La partie s'arrête quand un joueur atteint 66 points. Le plus petit score gagne.",
|
||||
icon: "Beef",
|
||||
colors: {
|
||||
primary: "#d81f26", // Rouge vif de la boite
|
||||
},
|
||||
};
|
||||
+34
-2
@@ -313,7 +313,38 @@ export default function GameOver() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{session.rounds.map((round) => (
|
||||
{session.rounds.map((round) => {
|
||||
// Manche jouée en équipes : une seule ligne avec l'équipe
|
||||
// gagnante, plutôt qu'une rangée de zéros.
|
||||
const team = round.winningTeamId
|
||||
? gameConfig?.teams?.find(
|
||||
(t) => t.id === round.winningTeamId,
|
||||
)
|
||||
: undefined;
|
||||
if (team) {
|
||||
return (
|
||||
<tr
|
||||
key={round.id}
|
||||
className="border-b border-border/30 last:border-0"
|
||||
>
|
||||
<td className="px-3 py-2 font-bold text-muted-foreground sticky left-0 bg-card z-10">
|
||||
#{round.roundNumber}
|
||||
</td>
|
||||
<td
|
||||
colSpan={session.players.length}
|
||||
className="px-3 py-2 text-center"
|
||||
>
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-full text-white text-xs font-black"
|
||||
style={{ backgroundColor: team.color }}
|
||||
>
|
||||
{team.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr
|
||||
key={round.id}
|
||||
className="border-b border-border/30 last:border-0"
|
||||
@@ -350,7 +381,8 @@ export default function GameOver() {
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<tr className="bg-secondary/50 font-black">
|
||||
<td className="px-3 py-2.5 sticky left-0 bg-secondary/50 z-10">
|
||||
Total
|
||||
|
||||
+117
-6
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Check, Undo2, Award, Trash2, BookOpen } from "lucide-react";
|
||||
import { Check, Undo2, Award, Trash2, BookOpen, Users } from "lucide-react";
|
||||
import * as Icons from "lucide-react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { db } from "../database/db";
|
||||
@@ -32,6 +32,7 @@ export default function PlayGame() {
|
||||
>({});
|
||||
|
||||
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
|
||||
// Keep active session players updated if a global player rename happens
|
||||
const playersInDb = useLiveQuery(() => db.players.toArray());
|
||||
@@ -103,6 +104,17 @@ export default function PlayGame() {
|
||||
return dbPlayer ? { ...p, name: dbPlayer.name } : p;
|
||||
});
|
||||
|
||||
// Jeux en équipes (ex: Moustache) : les premières manches ne notent que
|
||||
// l'équipe gagnante, la dernière sert au décompte final des points.
|
||||
const teams = gameConfig.teams;
|
||||
const isTeamGame = Boolean(teams?.length && gameConfig.teamRounds);
|
||||
const isTeamRound =
|
||||
isTeamGame && activeSession.rounds.length < (gameConfig.teamRounds ?? 0);
|
||||
const isFinalTally = isTeamGame && !isTeamRound;
|
||||
|
||||
const teamWins = (teamId: string) =>
|
||||
activeSession.rounds.filter((r) => r.winningTeamId === teamId).length;
|
||||
|
||||
const calculateTotalScore = (playerId: string) => {
|
||||
return calculatePlayerTotalScore(
|
||||
playerId,
|
||||
@@ -146,6 +158,17 @@ export default function PlayGame() {
|
||||
};
|
||||
|
||||
const handleValidateRound = async () => {
|
||||
// Manche jouée en équipes : on n'enregistre que l'équipe gagnante.
|
||||
if (isTeamRound) {
|
||||
if (!selectedTeamId) return;
|
||||
await addRound(
|
||||
activeSession.players.map((p) => ({ playerId: p.id, score: 0 })),
|
||||
selectedTeamId,
|
||||
);
|
||||
setSelectedTeamId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scores: RoundScore[] = activeSession.players.map((p) => {
|
||||
// For games with categories (like Harmonies), we store the sum as the main score
|
||||
// and keep the details in the `details` object
|
||||
@@ -305,8 +328,30 @@ export default function PlayGame() {
|
||||
</div>
|
||||
|
||||
<main className="flex-1 p-4 space-y-8 mt-2">
|
||||
{/* Manches gagnées par équipe (les points restent secrets jusqu'au bout) */}
|
||||
{isTeamGame && teams && (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{teams.map((team) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="flex-1 rounded-[1.5rem] p-4 flex flex-col items-center shadow-sm text-white"
|
||||
style={{ backgroundColor: team.color }}
|
||||
>
|
||||
<span className="text-3xl font-black tabular-nums leading-none">
|
||||
{teamWins(team.id)}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wide mt-1.5 opacity-90">
|
||||
{team.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leaderboard Summary (Floating Chips) */}
|
||||
<div className="flex overflow-x-auto pb-4 -mx-4 px-4 space-x-3 hide-scrollbar">
|
||||
<div
|
||||
className={`flex overflow-x-auto pb-4 -mx-4 px-4 space-x-3 hide-scrollbar ${isTeamRound ? "hidden" : ""}`}
|
||||
>
|
||||
{displayPlayers.map((player) => {
|
||||
const total = getLiveTotalScore(player.id);
|
||||
return (
|
||||
@@ -350,6 +395,10 @@ export default function PlayGame() {
|
||||
<h2 className="text-2xl font-black tracking-tighter drop-shadow-sm truncate">
|
||||
{categories
|
||||
? categories[currentCategoryIndex].label
|
||||
: isTeamRound
|
||||
? "Qui a gagné ?"
|
||||
: isFinalTally
|
||||
? "Décompte final"
|
||||
: isSingleRound
|
||||
? "Saisie des scores"
|
||||
: "À vos marques !"}
|
||||
@@ -389,7 +438,51 @@ export default function PlayGame() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories ? (
|
||||
{isTeamRound ? (
|
||||
<Card className="bg-background/80 backdrop-blur-xl border-0 shadow-xl rounded-[2.5rem] overflow-hidden">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<p className="text-sm font-medium text-muted-foreground text-center px-2">
|
||||
Les équipes changent à chaque manche : notez simplement celle
|
||||
qui l'emporte. Les points seront révélés à la fin.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{teams?.map((team) => {
|
||||
const selected = selectedTeamId === team.id;
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
onClick={() => setSelectedTeamId(team.id)}
|
||||
className={`rounded-[1.5rem] p-5 flex flex-col items-center gap-2.5 border-4 transition-all active:scale-95 ${
|
||||
selected ? "shadow-lg" : "opacity-70"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: `${team.color}1f`,
|
||||
borderColor: selected ? team.color : "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-14 h-14 rounded-full grid place-items-center text-white shadow-md"
|
||||
style={{ backgroundColor: team.color }}
|
||||
>
|
||||
{selected ? (
|
||||
<Check className="w-7 h-7" strokeWidth={3} />
|
||||
) : (
|
||||
<Users className="w-7 h-7" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="font-black text-sm"
|
||||
style={{ color: team.color }}
|
||||
>
|
||||
{team.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : categories ? (
|
||||
<Card className="bg-background/80 backdrop-blur-xl border-0 shadow-xl rounded-[2.5rem] overflow-hidden">
|
||||
<CardContent className="p-4 space-y-1">
|
||||
{displayPlayers.map((player, idx) => {
|
||||
@@ -544,7 +637,22 @@ export default function PlayGame() {
|
||||
#{round.roundNumber}
|
||||
</div>
|
||||
<div className="flex-1 flex justify-around items-center">
|
||||
{round.scores.map((s) => (
|
||||
{round.winningTeamId &&
|
||||
(() => {
|
||||
const t = teams?.find(
|
||||
(x) => x.id === round.winningTeamId,
|
||||
);
|
||||
return t ? (
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-white text-sm font-black shadow-sm"
|
||||
style={{ backgroundColor: t.color }}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{!round.winningTeamId &&
|
||||
round.scores.map((s) => (
|
||||
<div
|
||||
key={s.playerId}
|
||||
className="flex flex-col items-center"
|
||||
@@ -583,7 +691,8 @@ export default function PlayGame() {
|
||||
<div className="max-w-md mx-auto pointer-events-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full text-xl font-black shadow-xl h-16 rounded-[2rem] transition-transform active:scale-95"
|
||||
disabled={isTeamRound && !selectedTeamId}
|
||||
className="w-full text-xl font-black shadow-xl h-16 rounded-[2rem] transition-transform active:scale-95 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (
|
||||
categories &&
|
||||
@@ -601,7 +710,9 @@ export default function PlayGame() {
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-7 h-7 mr-2" strokeWidth={3} />
|
||||
{categories || isSingleRound
|
||||
{isTeamRound
|
||||
? "Valider la manche"
|
||||
: isFinalTally || categories || isSingleRound
|
||||
? "Valider le score"
|
||||
: "Valider la manche"}
|
||||
</>
|
||||
|
||||
@@ -17,7 +17,7 @@ interface GameState {
|
||||
profileId: string,
|
||||
locationId?: string,
|
||||
) => Promise<string>;
|
||||
addRound: (scores: RoundScore[]) => Promise<void>;
|
||||
addRound: (scores: RoundScore[], winningTeamId?: string) => Promise<void>;
|
||||
updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>;
|
||||
removeRound: (roundId: string) => Promise<void>;
|
||||
undoLastRound: () => Promise<void>;
|
||||
@@ -63,7 +63,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
return newSession.id;
|
||||
},
|
||||
|
||||
addRound: async (scores) => {
|
||||
addRound: async (scores, winningTeamId) => {
|
||||
const { activeSession, saveSession } = get();
|
||||
if (!activeSession) return;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
id: generateId(),
|
||||
roundNumber: activeSession.rounds.length + 1,
|
||||
scores,
|
||||
winningTeamId,
|
||||
};
|
||||
|
||||
set({
|
||||
|
||||
@@ -57,4 +57,6 @@ export const RESERVED_GAME_IDS = [
|
||||
"harmonie",
|
||||
"odin_cards",
|
||||
"chouineurs",
|
||||
"six_qui_prend",
|
||||
"moustache",
|
||||
];
|
||||
|
||||
@@ -24,6 +24,11 @@ export interface GameConfig {
|
||||
isEndGameCalculation?: boolean,
|
||||
) => number;
|
||||
fixedRounds?: number; // Used if scoreType is 'fixed_rounds'
|
||||
// Jeux en équipes tirées au sort à chaque manche (ex: Moustache) : on note
|
||||
// seulement l'équipe gagnante des `teamRounds` premières manches, puis la
|
||||
// dernière manche sert au décompte final des points de chaque joueur.
|
||||
teams?: GameTeam[];
|
||||
teamRounds?: number;
|
||||
scoringCategories?: ScoringCategory[];
|
||||
// Optional: categories that depend on the chosen options (e.g. an extra
|
||||
// scoring category enabled by a rule toggle). Falls back to scoringCategories.
|
||||
@@ -68,6 +73,13 @@ export interface Round {
|
||||
id: string;
|
||||
roundNumber: number;
|
||||
scores: RoundScore[];
|
||||
winningTeamId?: string; // manche jouée en équipes : équipe gagnante
|
||||
}
|
||||
|
||||
export interface GameTeam {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GameSession {
|
||||
|
||||
Reference in New Issue
Block a user