Harmonies : règle "Esprits de la nature" + logo vectorisé
Build and Publish Docker Image / build-and-push-image (push) Successful in 12s

- Harmonies : nouvelle option activable "Extension : Esprits de la nature" qui
  ajoute une catégorie de score en fin de partie (incluse dans le total)
- GameConfig.getScoringCategories(options) : catégories dépendantes des options
  (PlayGame et GameOver utilisent les catégories effectives)
- Nouveau logo vectorisé public/logo.svg + favicon SVG dans index.html

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zed
2026-07-12 16:05:15 +02:00
parent 1f400b78cb
commit c69a5195f5
6 changed files with 90 additions and 26 deletions
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="48x48" /> <link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta <meta
+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" role="img" aria-label="Skori">
<defs>
<linearGradient id="skori-planet" x1="20" y1="18" x2="78" y2="82">
<stop offset="0%" stop-color="#5eead4"/>
<stop offset="55%" stop-color="#14b8a6"/>
<stop offset="100%" stop-color="#0d9488"/>
</linearGradient>
<clipPath id="skori-clip"><circle cx="50" cy="50" r="28"/></clipPath>
</defs>
<!-- Orbit ring — back half -->
<path d="M 12 66 A 42 14 -18 0 1 88 40" stroke="#a5f3fc" stroke-width="4.5" stroke-linecap="round"/>
<!-- Planet body -->
<circle cx="50" cy="50" r="28" fill="url(#skori-planet)"/>
<!-- Flat two-tone shading + craters -->
<g clip-path="url(#skori-clip)">
<circle cx="72" cy="70" r="30" fill="#0f766e" opacity="0.5"/>
<circle cx="40" cy="40" r="6" fill="#99f6e4" opacity="0.55"/>
<circle cx="58" cy="56" r="4" fill="#0f766e" opacity="0.45"/>
<circle cx="46" cy="62" r="3" fill="#0f766e" opacity="0.4"/>
</g>
<!-- Orbit ring — front half -->
<path d="M 12 66 A 42 14 -18 0 0 88 40" stroke="#22d3ee" stroke-width="4.5" stroke-linecap="round"/>
<!-- Sparkle -->
<path d="M 80 20 q 2 6 8 8 q -6 2 -8 8 q -2 -6 -8 -8 q 6 -2 8 -8 z" fill="#fde68a"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+28 -8
View File
@@ -1,4 +1,18 @@
import { GameConfig } from "../../types"; import { GameConfig, ScoringCategory } from "../../types";
const BASE_CATEGORIES: ScoringCategory[] = [
{ id: "arbres", label: "Arbres" },
{ id: "montagnes", label: "Montagnes" },
{ id: "champs", label: "Champs" },
{ id: "batiments", label: "Bâtiments" },
{ id: "eau", label: "Rivière" },
{ id: "animaux", label: "Animaux" },
];
const NATURE_SPIRIT_CATEGORY: ScoringCategory = {
id: "esprits_nature",
label: "Esprits de la nature",
};
export const harmonieConfig: GameConfig = { export const harmonieConfig: GameConfig = {
imagePath: "/games-assets/harmonies-logo.webp", imagePath: "/games-assets/harmonies-logo.webp",
@@ -15,12 +29,18 @@ export const harmonieConfig: GameConfig = {
colors: { colors: {
primary: "#065f46", // Vert émeraude sombre tiré de la boite Harmonies primary: "#065f46", // Vert émeraude sombre tiré de la boite Harmonies
}, },
scoringCategories: [ options: [
{ id: "arbres", label: "Arbres" }, {
{ id: "montagnes", label: "Montagnes" }, id: "esprits_nature",
{ id: "champs", label: "Champs" }, label: "Extension : Esprits de la nature",
{ id: "batiments", label: "Bâtiments" }, type: "boolean",
{ id: "eau", label: "Rivière" }, defaultValue: false,
{ id: "animaux", label: "Animaux" }, },
], ],
scoringCategories: BASE_CATEGORIES,
// When the "Esprits de la nature" rule is on, add a final scoring category.
getScoringCategories: (options) =>
options?.esprits_nature
? [...BASE_CATEGORIES, NATURE_SPIRIT_CATEGORY]
: BASE_CATEGORIES,
}; };
+8 -3
View File
@@ -52,6 +52,11 @@ export default function GameOver() {
const Icon = gameConfig?.icon ? (Icons as any)[gameConfig.icon] : null; const Icon = gameConfig?.icon ? (Icons as any)[gameConfig.icon] : null;
// Effective scoring categories (may depend on the session's options).
const categories =
gameConfig?.getScoringCategories?.(session.options) ??
gameConfig?.scoringCategories;
const durationInMinutes = session.dateEnd const durationInMinutes = session.dateEnd
? Math.round((session.dateEnd - session.dateStart) / 60000) ? Math.round((session.dateEnd - session.dateStart) / 60000)
: 0; : 0;
@@ -68,7 +73,7 @@ export default function GameOver() {
const totals = session.players.map((p) => { const totals = session.players.map((p) => {
// If it's a categorized game (Harmonies), extract details from the first (and only) round // If it's a categorized game (Harmonies), extract details from the first (and only) round
let details = undefined; let details = undefined;
if (gameConfig?.scoringCategories && session.rounds.length > 0) { if (categories && session.rounds.length > 0) {
const pScore = session.rounds[0].scores.find((s) => s.playerId === p.id); const pScore = session.rounds[0].scores.find((s) => s.playerId === p.id);
details = pScore?.details; details = pScore?.details;
} }
@@ -232,9 +237,9 @@ export default function GameOver() {
<span className="text-2xl font-bold">{t.score}</span> <span className="text-2xl font-bold">{t.score}</span>
</div> </div>
{/* Display details if it's a categorized game like Harmonies */} {/* Display details if it's a categorized game like Harmonies */}
{t.details && gameConfig?.scoringCategories && ( {t.details && categories && (
<div className="mt-3 pt-3 border-t border-border/50 grid grid-cols-3 gap-2"> <div className="mt-3 pt-3 border-t border-border/50 grid grid-cols-3 gap-2">
{gameConfig.scoringCategories.map((cat) => { {categories.map((cat) => {
const val = t.details?.[cat.id]; const val = t.details?.[cat.id];
if (!val || val === "0") return null; if (!val || val === "0") return null;
return ( return (
+20 -15
View File
@@ -39,6 +39,11 @@ export default function PlayGame() {
const gameConfig = useGameConfig(activeSession?.gameId); const gameConfig = useGameConfig(activeSession?.gameId);
useGameTheme(gameConfig); useGameTheme(gameConfig);
// Effective scoring categories (may depend on the session's options).
const categories =
gameConfig?.getScoringCategories?.(activeSession?.options ?? {}) ??
gameConfig?.scoringCategories;
useEffect(() => { useEffect(() => {
if (sessionId) { if (sessionId) {
loadSession(sessionId); loadSession(sessionId);
@@ -109,7 +114,7 @@ export default function PlayGame() {
const getLiveTotalScore = (playerId: string) => { const getLiveTotalScore = (playerId: string) => {
let total = calculateTotalScore(playerId); let total = calculateTotalScore(playerId);
if (gameConfig?.scoringCategories) { if (categories) {
const pDetails = detailedScores[playerId] || {}; const pDetails = detailedScores[playerId] || {};
for (const key in pDetails) { for (const key in pDetails) {
const val = parseInt(pDetails[key] || "0", 10); const val = parseInt(pDetails[key] || "0", 10);
@@ -144,7 +149,7 @@ export default function PlayGame() {
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
if (gameConfig?.scoringCategories) { if (categories) {
const pDetails = detailedScores[p.id] || {}; const pDetails = detailedScores[p.id] || {};
let total = 0; let total = 0;
for (const key in pDetails) { for (const key in pDetails) {
@@ -318,14 +323,14 @@ export default function PlayGame() {
<section className="relative"> <section className="relative">
<div className="flex items-center justify-between px-2 mb-4"> <div className="flex items-center justify-between px-2 mb-4">
<h2 className="text-2xl font-black tracking-tighter drop-shadow-sm"> <h2 className="text-2xl font-black tracking-tighter drop-shadow-sm">
{gameConfig.scoringCategories {categories
? gameConfig.scoringCategories[currentCategoryIndex].label ? categories[currentCategoryIndex].label
: isSingleRound : isSingleRound
? "Saisie des scores" ? "Saisie des scores"
: "À vos marques !"} : "À vos marques !"}
</h2> </h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{gameConfig.scoringCategories && currentCategoryIndex > 0 && ( {categories && currentCategoryIndex > 0 && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -338,7 +343,7 @@ export default function PlayGame() {
)} )}
{activeSession.rounds.length > 0 && {activeSession.rounds.length > 0 &&
!isSingleRound && !isSingleRound &&
!gameConfig.scoringCategories && ( !categories && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -349,22 +354,22 @@ export default function PlayGame() {
Annuler manche Annuler manche
</Button> </Button>
)} )}
{gameConfig.scoringCategories && ( {categories && (
<span className="text-xs font-black bg-primary/10 text-primary px-3 py-1.5 rounded-full"> <span className="text-xs font-black bg-primary/10 text-primary px-3 py-1.5 rounded-full">
Étape {currentCategoryIndex + 1} /{" "} Étape {currentCategoryIndex + 1} /{" "}
{gameConfig.scoringCategories.length} {categories.length}
</span> </span>
)} )}
</div> </div>
</div> </div>
{gameConfig.scoringCategories ? ( {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) => {
const pDetails = detailedScores[player.id] || {}; const pDetails = detailedScores[player.id] || {};
const catId = const catId =
gameConfig.scoringCategories![currentCategoryIndex].id; categories![currentCategoryIndex].id;
return ( return (
<div <div
@@ -555,8 +560,8 @@ export default function PlayGame() {
className="w-full text-xl font-black shadow-xl h-16 rounded-[2rem] transition-transform active:scale-95" className="w-full text-xl font-black shadow-xl h-16 rounded-[2rem] transition-transform active:scale-95"
onClick={() => { onClick={() => {
if ( if (
gameConfig?.scoringCategories && categories &&
currentCategoryIndex < gameConfig.scoringCategories.length - 1 currentCategoryIndex < categories.length - 1
) { ) {
setCurrentCategoryIndex((prev) => prev + 1); setCurrentCategoryIndex((prev) => prev + 1);
} else { } else {
@@ -564,13 +569,13 @@ export default function PlayGame() {
} }
}} }}
> >
{gameConfig?.scoringCategories && {categories &&
currentCategoryIndex < gameConfig.scoringCategories.length - 1 ? ( currentCategoryIndex < categories.length - 1 ? (
"Suivant" "Suivant"
) : ( ) : (
<> <>
<Check className="w-7 h-7 mr-2" strokeWidth={3} /> <Check className="w-7 h-7 mr-2" strokeWidth={3} />
{gameConfig?.scoringCategories || isSingleRound {categories || isSingleRound
? "Valider le score" ? "Valider le score"
: "Valider la manche"} : "Valider la manche"}
</> </>
+3
View File
@@ -22,6 +22,9 @@ export interface GameConfig {
) => number; ) => number;
fixedRounds?: number; // Used if scoreType is 'fixed_rounds' fixedRounds?: number; // Used if scoreType is 'fixed_rounds'
scoringCategories?: ScoringCategory[]; 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.
getScoringCategories?: (options: Record<string, any>) => ScoringCategory[];
description?: string; description?: string;
icon?: string; icon?: string;
imagePath?: string; // Optional custom logo image (SVG, PNG, WebP...) imagePath?: string; // Optional custom logo image (SVG, PNG, WebP...)