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 { caboConfig } from "./cabo/config";
|
||||||
import { chouineursConfig } from "./chouineurs/config";
|
import { chouineursConfig } from "./chouineurs/config";
|
||||||
import { harmonieConfig } from "./harmonie/config";
|
import { harmonieConfig } from "./harmonie/config";
|
||||||
|
import { moustacheConfig } from "./moustache/config";
|
||||||
import { odinCardsConfig } from "./odin_cards/config";
|
import { odinCardsConfig } from "./odin_cards/config";
|
||||||
|
import { sixQuiPrendConfig } from "./six_qui_prend/config";
|
||||||
import { skyjoConfig } from "./skyjo/config";
|
import { skyjoConfig } from "./skyjo/config";
|
||||||
|
|
||||||
// Built-in games (compiled into the app).
|
// Built-in games (compiled into the app).
|
||||||
@@ -19,6 +21,8 @@ export const games: GameConfig[] = [
|
|||||||
harmonieConfig,
|
harmonieConfig,
|
||||||
odinCardsConfig,
|
odinCardsConfig,
|
||||||
chouineursConfig,
|
chouineursConfig,
|
||||||
|
sixQuiPrendConfig,
|
||||||
|
moustacheConfig,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Imported games for the active profile, kept in sync in-memory so the
|
// 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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
|
<tr
|
||||||
key={round.id}
|
key={round.id}
|
||||||
className="border-b border-border/30 last:border-0"
|
className="border-b border-border/30 last:border-0"
|
||||||
@@ -350,7 +381,8 @@ export default function GameOver() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<tr className="bg-secondary/50 font-black">
|
<tr className="bg-secondary/50 font-black">
|
||||||
<td className="px-3 py-2.5 sticky left-0 bg-secondary/50 z-10">
|
<td className="px-3 py-2.5 sticky left-0 bg-secondary/50 z-10">
|
||||||
Total
|
Total
|
||||||
|
|||||||
+117
-6
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 * as Icons from "lucide-react";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
@@ -32,6 +32,7 @@ export default function PlayGame() {
|
|||||||
>({});
|
>({});
|
||||||
|
|
||||||
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Keep active session players updated if a global player rename happens
|
// Keep active session players updated if a global player rename happens
|
||||||
const playersInDb = useLiveQuery(() => db.players.toArray());
|
const playersInDb = useLiveQuery(() => db.players.toArray());
|
||||||
@@ -103,6 +104,17 @@ export default function PlayGame() {
|
|||||||
return dbPlayer ? { ...p, name: dbPlayer.name } : p;
|
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) => {
|
const calculateTotalScore = (playerId: string) => {
|
||||||
return calculatePlayerTotalScore(
|
return calculatePlayerTotalScore(
|
||||||
playerId,
|
playerId,
|
||||||
@@ -146,6 +158,17 @@ export default function PlayGame() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleValidateRound = async () => {
|
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) => {
|
const scores: RoundScore[] = activeSession.players.map((p) => {
|
||||||
// For games with categories (like Harmonies), we store the sum as the main score
|
// For games with categories (like Harmonies), we store the sum as the main score
|
||||||
// and keep the details in the `details` object
|
// and keep the details in the `details` object
|
||||||
@@ -305,8 +328,30 @@ export default function PlayGame() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="flex-1 p-4 space-y-8 mt-2">
|
<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) */}
|
{/* 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) => {
|
{displayPlayers.map((player) => {
|
||||||
const total = getLiveTotalScore(player.id);
|
const total = getLiveTotalScore(player.id);
|
||||||
return (
|
return (
|
||||||
@@ -350,6 +395,10 @@ export default function PlayGame() {
|
|||||||
<h2 className="text-2xl font-black tracking-tighter drop-shadow-sm truncate">
|
<h2 className="text-2xl font-black tracking-tighter drop-shadow-sm truncate">
|
||||||
{categories
|
{categories
|
||||||
? categories[currentCategoryIndex].label
|
? categories[currentCategoryIndex].label
|
||||||
|
: isTeamRound
|
||||||
|
? "Qui a gagné ?"
|
||||||
|
: isFinalTally
|
||||||
|
? "Décompte final"
|
||||||
: isSingleRound
|
: isSingleRound
|
||||||
? "Saisie des scores"
|
? "Saisie des scores"
|
||||||
: "À vos marques !"}
|
: "À vos marques !"}
|
||||||
@@ -389,7 +438,51 @@ export default function PlayGame() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<Card className="bg-background/80 backdrop-blur-xl border-0 shadow-xl rounded-[2.5rem] overflow-hidden">
|
||||||
<CardContent className="p-4 space-y-1">
|
<CardContent className="p-4 space-y-1">
|
||||||
{displayPlayers.map((player, idx) => {
|
{displayPlayers.map((player, idx) => {
|
||||||
@@ -544,7 +637,22 @@ export default function PlayGame() {
|
|||||||
#{round.roundNumber}
|
#{round.roundNumber}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex justify-around items-center">
|
<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
|
<div
|
||||||
key={s.playerId}
|
key={s.playerId}
|
||||||
className="flex flex-col items-center"
|
className="flex flex-col items-center"
|
||||||
@@ -583,7 +691,8 @@ export default function PlayGame() {
|
|||||||
<div className="max-w-md mx-auto pointer-events-auto">
|
<div className="max-w-md mx-auto pointer-events-auto">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
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={() => {
|
onClick={() => {
|
||||||
if (
|
if (
|
||||||
categories &&
|
categories &&
|
||||||
@@ -601,7 +710,9 @@ export default function PlayGame() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className="w-7 h-7 mr-2" strokeWidth={3} />
|
<Check className="w-7 h-7 mr-2" strokeWidth={3} />
|
||||||
{categories || isSingleRound
|
{isTeamRound
|
||||||
|
? "Valider la manche"
|
||||||
|
: isFinalTally || categories || isSingleRound
|
||||||
? "Valider le score"
|
? "Valider le score"
|
||||||
: "Valider la manche"}
|
: "Valider la manche"}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface GameState {
|
|||||||
profileId: string,
|
profileId: string,
|
||||||
locationId?: string,
|
locationId?: string,
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
addRound: (scores: RoundScore[]) => Promise<void>;
|
addRound: (scores: RoundScore[], winningTeamId?: string) => Promise<void>;
|
||||||
updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>;
|
updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>;
|
||||||
removeRound: (roundId: string) => Promise<void>;
|
removeRound: (roundId: string) => Promise<void>;
|
||||||
undoLastRound: () => Promise<void>;
|
undoLastRound: () => Promise<void>;
|
||||||
@@ -63,7 +63,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
return newSession.id;
|
return newSession.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
addRound: async (scores) => {
|
addRound: async (scores, winningTeamId) => {
|
||||||
const { activeSession, saveSession } = get();
|
const { activeSession, saveSession } = get();
|
||||||
if (!activeSession) return;
|
if (!activeSession) return;
|
||||||
|
|
||||||
@@ -71,6 +71,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
id: generateId(),
|
id: generateId(),
|
||||||
roundNumber: activeSession.rounds.length + 1,
|
roundNumber: activeSession.rounds.length + 1,
|
||||||
scores,
|
scores,
|
||||||
|
winningTeamId,
|
||||||
};
|
};
|
||||||
|
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -57,4 +57,6 @@ export const RESERVED_GAME_IDS = [
|
|||||||
"harmonie",
|
"harmonie",
|
||||||
"odin_cards",
|
"odin_cards",
|
||||||
"chouineurs",
|
"chouineurs",
|
||||||
|
"six_qui_prend",
|
||||||
|
"moustache",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export interface GameConfig {
|
|||||||
isEndGameCalculation?: boolean,
|
isEndGameCalculation?: boolean,
|
||||||
) => number;
|
) => number;
|
||||||
fixedRounds?: number; // Used if scoreType is 'fixed_rounds'
|
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[];
|
scoringCategories?: ScoringCategory[];
|
||||||
// Optional: categories that depend on the chosen options (e.g. an extra
|
// Optional: categories that depend on the chosen options (e.g. an extra
|
||||||
// scoring category enabled by a rule toggle). Falls back to scoringCategories.
|
// scoring category enabled by a rule toggle). Falls back to scoringCategories.
|
||||||
@@ -68,6 +73,13 @@ export interface Round {
|
|||||||
id: string;
|
id: string;
|
||||||
roundNumber: number;
|
roundNumber: number;
|
||||||
scores: RoundScore[];
|
scores: RoundScore[];
|
||||||
|
winningTeamId?: string; // manche jouée en équipes : équipe gagnante
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameTeam {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameSession {
|
export interface GameSession {
|
||||||
|
|||||||
Reference in New Issue
Block a user