Ajoute La Corne d'Abondance, jeu de fusion façon Watermelon Game
Build and deploy / deploy (push) Successful in 35s
Build and deploy / deploy (push) Successful in 35s
Nouveau mini-jeu à /corne : des avatars de Citoyens tombent dans un bac (physique matter-js), deux avatars de même taille qui se touchent fusionnent en un plus gros, jusqu'au débordement soutenu (5s de grâce, pièces clignotantes pour prévenir) qui met fin à la partie. Même mécanique de gloires que Le Vol d'Icare (record personnel, classement, gel + attribution automatique au Tribunal via pg_cron). Un palier de taille par Citoyen (pas les Archontes) plutôt qu'un nombre arbitraire : citizens[tier] donne l'association palier → Citoyen, fixe pour tout le monde (les Citoyens sont triés par pseudo côté serveur, un ordre déterministe). Rendu en DOM pour réutiliser le composant Avatar existant (étendu pour accepter une taille en pixels arbitraire) plutôt que de redessiner des images sur canvas — position et rotation de chaque pièce appliquées en impératif depuis la boucle physique, jamais via setState. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
|
||||
|
||||
## 1. État actuel
|
||||
|
||||
V1 à V10 sont livrées :
|
||||
V1 à V11 sont livrées :
|
||||
- **V1** : comptes, profils, leaderboard statique.
|
||||
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
|
||||
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
|
||||
@@ -17,6 +17,7 @@ V1 à V10 sont livrées :
|
||||
- **V8** : les Citoyens peuvent désormais proposer une question pour Le Char — une par personne (upsert), modifiable autant de fois que voulu depuis une nouvelle section sur `/profile`, jusqu'à ce que la date du Tribunal (`settings.tribunal_date`) soit atteinte (même mécanisme de gel que les scores d'Icare, no-op silencieux plutôt qu'une erreur). Les Archontes modèrent ces suggestions depuis une nouvelle section « Suggestions des Citoyens » sur `/char/questions` : chaque proposition affiche l'auteur, avec un bouton « Ajouter à la banque » (la copie dans `chariot_questions`, à la fin de l'ordre existant) et un bouton de rejet (suppression, confirmation à deux temps comme le reste de la page). Par ailleurs, `/admin` affiche maintenant l'adresse email de chaque membre (RPC `admin_list_members()`, seul moyen d'exposer `auth.users.email` aux juges sans passer par la `service_role key` côté client).
|
||||
- **V9** : **Le Mur de la Honte** (`/mur`) — chaque Citoyen peut publier une note anonyme par jour (dix places maximum sur le mur, remis à zéro chaque jour — pas de suppression, juste un filtrage par date). L'anonymat n'est qu'à moitié réel : les Archontes voient une section « Historique complet » (filtrable par jour) avec l'auteur de chaque note jamais publiée, cohérent avec le thème du site (les Archontes ont un ascendant sur les Citoyens). Pas de gel lié à la date du Tribunal : contrairement au Char, c'est une mécanique de toute la semaine, pas de la seule soirée de clôture.
|
||||
- **V10** : **L'Urne de l'Agora** (`/urne`) — vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque Citoyen dépose un jeton par jour sur une personne de son choix (jamais lui-même), confirmation à deux temps avant l'envoi (vote définitif pour la journée, aucune RPC de modification). Contrairement au Mur de la Honte, les jetons s'accumulent sur toute la semaine dans un classement public et cumulatif — **identique pour tout le monde, Archontes compris** : contraste volontaire avec le Mur, ici les Archontes n'ont aucune vision privilégiée sur qui a voté pour qui, seul le total par personne est public. Les Archontes ne votent pas et ne peuvent pas recevoir de jetons.
|
||||
- **V11** : deuxième mini-jeu compétitif, **La Corne d'Abondance** (`/corne`) — jeu de fusion façon « Watermelon Game » : des avatars de Citoyens tombent dans un bac (physique gérée par `matter-js`, seule dépendance de ce type dans le projet — l'empilement stable de cercles est notoirement difficile à obtenir sans bugs de superposition/tremblement en physique artisanale), deux avatars de même taille qui se touchent fusionnent en un plus gros, jusqu'au débordement soutenu (5 secondes de délai de grâce, pour ne pas sanctionner un simple rebond) qui met fin à la partie — les pièces qui dépassent la ligne de danger clignotent (`.animate-danger-blink`) pour signaler que le compte à rebours est en cours. **Un palier de taille par Citoyen** (pas les Archontes) plutôt qu'un nombre de paliers arbitraire (`buildTierRadii`, `src/lib/melon/constants.ts`) : `citizens[tier]` (indexation directe, sans tirage) donne l'association palier → Citoyen, **fixe pour tout le monde** (pas juste pour une partie) puisque `corne/page.tsx` trie déjà les Citoyens par pseudo, un ordre déterministe qui ne dépend d'aucun choix arbitraire de personnes précises. Même mécanique de gloires que Le Vol d'Icare : record personnel all-time, scores figés à la date du Tribunal, gloires du top 3 attribuées automatiquement (`pg_cron`, sans intervention d'un Archonte) — n'importe qui (Archontes compris) peut jouer, seul le pool d'avatars affichés est limité aux Citoyens. Rendu en DOM (pas canvas) pour réutiliser tel quel le composant `Avatar` existant (`src/components/avatar.tsx`, étendu pour accepter une taille en pixels arbitraire en plus des tailles nommées) — la position **et la rotation** de chaque pièce sont appliquées en impératif (`style.transform`, `translate(...) rotate(body.angle)`) à 60 fps depuis la boucle physique matter-js, jamais via `setState`. Le halo de l'animation d'apparition doit porter `rounded-full` lui-même, sinon son ombre portée dessine un carré visible autour du rond ; l'apparition d'une pièce utilise un simple fondu (`.animate-fade-in`) plutôt que l'animation avec mise à l'échelle utilisée ailleurs dans l'app (`.animate-reveal`), pour que sa taille exacte soit lisible dès la première frame. Trait de visée vertical + preview flottante au-dessus du bac pour montrer où la prochaine pièce va tomber.
|
||||
|
||||
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
|
||||
|
||||
@@ -28,6 +29,7 @@ Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons
|
||||
- **Style** : Tailwind CSS v4 (design tokens en variables CSS, voir `globals.css`)
|
||||
- **Auth + Base + Stockage + Temps réel** : Supabase (Auth, Postgres, Storage, Realtime)
|
||||
- **Recadrage photo** : `react-easy-crop` + canvas natif (resize/compression client, jamais d'upload de l'image brute)
|
||||
- **Physique 2D** : `matter-js` (uniquement le moteur de simulation — `Engine`/`Composite`/`Bodies`/`Body`/`Events`, jamais son module `Render`), pour La Corne d'Abondance
|
||||
- **Déploiement** : self-hosted, Docker Compose + Traefik (reverse proxy), Supabase (managé) — CI via Gitea Actions (`.gitea/workflows/deploy.yml`) qui build le standalone Next.js et le POST à un webhook (`docker/webhook/receive.py`) déclenchant un hot-swap sans downtime (`docker/entrypoint.sh`) ; domaine injecté par `APP_DOMAIN` (`.env.docker`, gitignored — seul `.env.docker.example` est versionné) dans le label Traefik `Host()`. Domaine actuel : `agora.alxczl.fr`.
|
||||
|
||||
Next.js 16 a renommé `middleware.ts` en `proxy.ts` (fichier `src/proxy.ts`, fonction `proxy`). `src/lib/supabase/middleware.ts` est un nom de fichier utilitaire interne, sans rapport avec cette convention.
|
||||
@@ -67,6 +69,13 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
|
||||
- `settings.icarus_points_awarded` : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char).
|
||||
- Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale).
|
||||
|
||||
### `public.melon_scores` (La Corne d'Abondance)
|
||||
|
||||
- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`).
|
||||
- `settings.melon_points_awarded` : marqueur d'idempotence, même principe que `icarus_points_awarded`.
|
||||
- `submit_melon_score(p_score)` / `award_melon_points_if_due()` : copies conformes de `submit_icarus_score`/`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`).
|
||||
- Le pool d'avatars des pièces (Citoyens uniquement, filtré côté serveur dans `corne/page.tsx` — un palier de taille par Citoyen) et les paliers/rayons du jeu vivent côté client (`src/lib/melon/constants.ts`), pas en base. N'importe qui peut jouer et apparaître dans `melon_scores`/le classement (Archontes compris) — seul le skin des pièces est restreint aux Citoyens.
|
||||
|
||||
### `public.chariot_questions` / `public.chariot_slots` / `public.chariot_entries` (Le Jeu de l'Agora — Le Char)
|
||||
|
||||
- `chariot_questions` : `id, text, position, created_at` — banque de questions ordonnée, réordonnée par swap de `position` (pas de contrainte unique dessus, deux `update` non transactionnels ; toujours trier `order by position, created_at`). RLS : lecture ouverte, écriture réservée aux juges (comme `days`/`events`). Gérée exclusivement depuis `/char/questions`, jamais depuis `/char`.
|
||||
@@ -143,6 +152,7 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
|
||||
| `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email |
|
||||
| `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges, date/décompte du Tribunal en en-tête (`src/lib/tribunal-date.ts`, partagé avec `/calendrier`) |
|
||||
| `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement |
|
||||
| `/corne` | « La Corne d'Abondance » — jeu de fusion façon Watermelon Game (avatars aléatoires, physique `matter-js`), même mécanique de gloires que Le Vol d'Icare |
|
||||
| `/char` | « Le Jeu de l'Agora » — format paysage (projeté) : Le Char (2 colonnes de 3, boutons de duel), Le Gardien du Silence (tirage aléatoire, minuteur discret) et La Roulette (tirage au sort animé) |
|
||||
| `/char/questions` | Banque de questions du Char (ajout/édition/réordonnancement/afficher-masquer) + modération des suggestions des Citoyens (ajouter à la banque/rejeter) — juges uniquement, pilotée depuis un téléphone pendant que `/char` est projetée |
|
||||
| `/mur` | « Le Mur de la Honte » — une note anonyme par Citoyen par jour (dix places, remis à zéro chaque jour) ; les Archontes y voient en plus l'historique complet avec l'auteur de chaque note |
|
||||
@@ -152,7 +162,7 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
|
||||
| `/profile` | Pseudo (verrouillable), photo, déconnexion, et pour les Citoyens : proposition d'une question pour Le Char (modifiable jusqu'au Tribunal) |
|
||||
| `/admin` | « Admin » — juges uniquement, **vérifié côté serveur** (Server Component), habillée d'une police monospace (rupture volontaire avec le thème grec) — gestion des membres, reset du repère de classement |
|
||||
|
||||
`/leaderboard`, `/profile`, `/admin`, `/journal`, `/calendrier`, `/icare`, `/char` (et donc `/char/questions`, couverte par le même préfixe), `/mur`, `/urne` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` et `/char/questions` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — la page elle-même vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front).
|
||||
`/leaderboard`, `/profile`, `/admin`, `/journal`, `/calendrier`, `/icare`, `/corne`, `/char` (et donc `/char/questions`, couverte par le même préfixe), `/mur`, `/urne` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` et `/char/questions` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — la page elle-même vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front).
|
||||
|
||||
Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée.
|
||||
|
||||
|
||||
Generated
+15
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.109.0",
|
||||
"matter-js": "^0.20.0",
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -17,6 +18,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/matter-js": "^0.20.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -1664,6 +1666,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/matter-js": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/matter-js/-/matter-js-0.20.2.tgz",
|
||||
"integrity": "sha512-3PPKy3QxvZ89h9+wdBV2488I1JLVs7DEpIkPvgO8JC1mUdiVSO37ZIvVctOTD7hIq8OAL2gJ3ugGSuUip6DhCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -5111,6 +5120,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/matter-js": {
|
||||
"version": "0.20.0",
|
||||
"resolved": "https://registry.npmjs.org/matter-js/-/matter-js-0.20.0.tgz",
|
||||
"integrity": "sha512-iC9fYR7zVT3HppNnsFsp9XOoQdQN2tUyfaKg4CHLH8bN+j6GT4Gw7IH2rP0tflAebrHFw730RR3DkVSZRX8hwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.109.0",
|
||||
"matter-js": "^0.20.0",
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -18,6 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/matter-js": "^0.20.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { Podium } from "@/components/podium";
|
||||
import { IconClose, IconHorn } from "@/components/icons";
|
||||
import { computeRanksBy } from "@/lib/ranking";
|
||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import { MelonGame } from "./melon-game";
|
||||
import type { MelonScoreEntry, MemberRow } from "./page";
|
||||
|
||||
export function CorneView({
|
||||
citizens,
|
||||
initialLeaderboard,
|
||||
initialOwnBestScore,
|
||||
isFrozen,
|
||||
}: {
|
||||
citizens: MemberRow[];
|
||||
initialLeaderboard: MelonScoreEntry[];
|
||||
initialOwnBestScore: number | null;
|
||||
isFrozen: boolean;
|
||||
}) {
|
||||
const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
|
||||
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
|
||||
const [showRanking, setShowRanking] = useState(false);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const supabase = createClient();
|
||||
const [{ data: scores }, { data: members }] = await Promise.all([
|
||||
supabase.from("melon_scores").select("user_id, best_score").order("best_score", { ascending: false }),
|
||||
supabase.from("profiles").select("id, pseudo, avatar_url"),
|
||||
]);
|
||||
|
||||
const memberById = new Map((members ?? []).map((member) => [member.id, member]));
|
||||
|
||||
setLeaderboard(
|
||||
(scores ?? []).map((row) => ({
|
||||
id: row.user_id,
|
||||
pseudo: memberById.get(row.user_id)?.pseudo ?? "Un Citoyen",
|
||||
avatar_url: memberById.get(row.user_id)?.avatar_url ?? null,
|
||||
best_score: row.best_score,
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
let channel: ReturnType<typeof supabase.channel> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
waitForRealtimeAuth(supabase).then(() => {
|
||||
if (cancelled) return;
|
||||
channel = supabase
|
||||
.channel("melon-scores")
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "melon_scores" }, () => {
|
||||
refetch();
|
||||
})
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (channel) supabase.removeChannel(channel);
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
async function handleFinish(score: number) {
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase.rpc("submit_melon_score", { p_score: score });
|
||||
if (!error && data) {
|
||||
setOwnBestScore(data.best_score);
|
||||
refetch();
|
||||
}
|
||||
}
|
||||
|
||||
// Le classement vit dans une modale, hors du flux de mise en page du jeu
|
||||
// (même leçon qu'Icare : une mise à jour Realtime pendant une partie ne
|
||||
// doit jamais pouvoir perturber le jeu en cours).
|
||||
const ranked = useMemo(() => computeRanksBy(leaderboard, (a, b) => b.best_score - a.best_score), [leaderboard]);
|
||||
const podiumProfiles = ranked.filter((p) => p.rank <= 3);
|
||||
const rest = ranked.filter((p) => p.rank > 3);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 px-4 py-8">
|
||||
<div className="text-center">
|
||||
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
|
||||
La Corne d'Abondance
|
||||
</h1>
|
||||
<p className="font-serif text-sm text-marble/60 italic">
|
||||
Fais fusionner les Citoyens jusqu'au débordement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRanking(true)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-marble/80 uppercase tracking-wide transition hover:bg-gold/10"
|
||||
>
|
||||
<IconHorn className="h-4 w-4" />
|
||||
Classement
|
||||
</button>
|
||||
|
||||
<MelonGame citizens={citizens} onFinish={handleFinish} />
|
||||
|
||||
{ownBestScore !== null && (
|
||||
<p className="text-center font-heading text-sm tracking-wide text-marble">
|
||||
Ton record : <span className="text-gold-bright">{ownBestScore}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="max-w-sm text-center text-xs text-marble/60">
|
||||
{isFrozen
|
||||
? "Les gloires du podium ont été attribuées automatiquement au début du Tribunal."
|
||||
: "Au début du Tribunal, les scores seront figés et le podium recevra des gloires automatiquement — aucun Archonte n'a besoin d'intervenir."}
|
||||
</p>
|
||||
|
||||
{showRanking && (
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm">
|
||||
<div className="marble-surface flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-gold/40 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-gold/20 px-4 py-3">
|
||||
<h2 className="font-heading text-sm tracking-[0.1em] text-text-marble uppercase">Classement</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRanking(false)}
|
||||
className="rounded-md p-1 text-text-mut transition hover:bg-gold/10 hover:text-text-marble"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<IconClose className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto px-4 py-4">
|
||||
{podiumProfiles.length > 0 && (
|
||||
<Podium profiles={podiumProfiles} renderValue={(member) => member.best_score} />
|
||||
)}
|
||||
|
||||
{rest.length > 0 && (
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{rest.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-gold/25 px-3 py-2"
|
||||
>
|
||||
<span className="w-5 shrink-0 text-center font-heading text-sm text-text-mut">
|
||||
{entry.rank}
|
||||
</span>
|
||||
<Avatar pseudo={entry.pseudo} avatarUrl={entry.avatar_url} size="xs" />
|
||||
<span className="flex-1 truncate text-sm font-medium text-text-marble">{entry.pseudo}</span>
|
||||
<span className="font-heading text-sm font-semibold text-sea">{entry.best_score}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{ranked.length === 0 && (
|
||||
<p className="text-center text-sm text-text-mut">Personne n'a encore joué.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import Matter from "matter-js";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import {
|
||||
buildTierRadii,
|
||||
CONTAINER_HEIGHT,
|
||||
CONTAINER_WIDTH,
|
||||
DANGER_LINE_Y,
|
||||
DROP_COOLDOWN_MS,
|
||||
DROP_MAX_TIER_CAP,
|
||||
DROP_MIN_TIER,
|
||||
FIXED_DT_MS,
|
||||
GAME_OVER_GRACE_MS,
|
||||
MAX_FRAME_DT_MS,
|
||||
WALL_THICKNESS,
|
||||
} from "@/lib/melon/constants";
|
||||
import type { MemberRow } from "./page";
|
||||
|
||||
type Status = "playing" | "dead";
|
||||
|
||||
type Piece = { id: number; tier: number };
|
||||
|
||||
type PendingMerge = { pieceIdA: number; pieceIdB: number; tier: number; x: number; y: number };
|
||||
|
||||
type BodyMeta = { pieceId: number; tier: number };
|
||||
|
||||
function randomTier(min: number, max: number): number {
|
||||
return min + Math.floor(Math.random() * (max - min + 1));
|
||||
}
|
||||
|
||||
export function MelonGame({ citizens, onFinish }: { citizens: MemberRow[]; onFinish: (score: number) => void }) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const guideLineRef = useRef<HTMLDivElement | null>(null);
|
||||
const nodeRefs = useRef(new Map<number, HTMLDivElement>());
|
||||
|
||||
// Un palier de taille par Citoyen, pas un nombre arbitraire — voir
|
||||
// buildTierRadii. citizens[tier] (indexation directe, sans tirage) donne
|
||||
// l'association palier → Citoyen : fixe pour tout le monde, pas juste
|
||||
// pour une partie — corne/page.tsx trie déjà les Citoyens par pseudo, ce
|
||||
// qui donne un ordre déterministe et stable sans dépendre d'un choix
|
||||
// arbitraire de personnes précises.
|
||||
const tierRadii = useMemo(() => buildTierRadii(citizens.length), [citizens.length]);
|
||||
const maxTier = tierRadii.length - 1;
|
||||
const dropMaxTier = Math.min(DROP_MAX_TIER_CAP, maxTier);
|
||||
|
||||
const [status, setStatus] = useState<Status>("playing");
|
||||
const [score, setScore] = useState(0);
|
||||
const [pieces, setPieces] = useState<Piece[]>([]);
|
||||
const [nextTier, setNextTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier));
|
||||
|
||||
const onFinishRef = useRef(onFinish);
|
||||
useEffect(() => {
|
||||
onFinishRef.current = onFinish;
|
||||
}, [onFinish]);
|
||||
|
||||
const statusRef = useRef<Status>("playing");
|
||||
const scoreRef = useRef(0);
|
||||
const nextTierRef = useRef(nextTier);
|
||||
const dropXRef = useRef(CONTAINER_WIDTH / 2);
|
||||
const engineRef = useRef<Matter.Engine | null>(null);
|
||||
const bodiesRef = useRef(new Map<number, Matter.Body>());
|
||||
const nextPieceIdRef = useRef(0);
|
||||
const lastDropAtRef = useRef(-Infinity);
|
||||
const pendingMergesRef = useRef<PendingMerge[]>([]);
|
||||
const mergingBodyIdsRef = useRef(new Set<number>());
|
||||
const dangerSinceRef = useRef<number | null>(null);
|
||||
|
||||
const spawnPiece = useCallback(
|
||||
(tier: number, x: number, y: number): Piece => {
|
||||
const id = nextPieceIdRef.current++;
|
||||
const radius = tierRadii[tier];
|
||||
const body = Matter.Bodies.circle(x, y, radius, {
|
||||
restitution: 0.2,
|
||||
friction: 0.3,
|
||||
frictionAir: 0.001,
|
||||
});
|
||||
const meta: BodyMeta = { pieceId: id, tier };
|
||||
body.plugin = meta;
|
||||
Matter.Composite.add(engineRef.current!.world, body);
|
||||
bodiesRef.current.set(id, body);
|
||||
return { id, tier };
|
||||
},
|
||||
[tierRadii],
|
||||
);
|
||||
|
||||
// Positionne dropXRef (utilisé au lâcher) ainsi que la preview et le
|
||||
// trait de visée qui suivent le pointeur — rappelée après chaque
|
||||
// changement de palier (le rayon change, donc le décalage aussi) pour
|
||||
// resynchroniser sans jamais lire de ref pendant un rendu (interdit par
|
||||
// react-hooks/refs).
|
||||
const setDropX = useCallback(
|
||||
(x: number) => {
|
||||
const radius = tierRadii[nextTierRef.current];
|
||||
const clamped = Math.min(CONTAINER_WIDTH - WALL_THICKNESS - radius, Math.max(WALL_THICKNESS + radius, x));
|
||||
dropXRef.current = clamped;
|
||||
if (previewRef.current) {
|
||||
previewRef.current.style.transform = `translateX(${clamped - radius}px)`;
|
||||
}
|
||||
if (guideLineRef.current) {
|
||||
guideLineRef.current.style.transform = `translateX(${clamped}px)`;
|
||||
}
|
||||
},
|
||||
[tierRadii],
|
||||
);
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
const engine = engineRef.current;
|
||||
if (engine) {
|
||||
for (const body of bodiesRef.current.values()) {
|
||||
Matter.Composite.remove(engine.world, body);
|
||||
}
|
||||
}
|
||||
bodiesRef.current.clear();
|
||||
nodeRefs.current.clear();
|
||||
pendingMergesRef.current = [];
|
||||
mergingBodyIdsRef.current.clear();
|
||||
dangerSinceRef.current = null;
|
||||
scoreRef.current = 0;
|
||||
statusRef.current = "playing";
|
||||
const firstNextTier = randomTier(DROP_MIN_TIER, dropMaxTier);
|
||||
nextTierRef.current = firstNextTier;
|
||||
setNextTier(firstNextTier);
|
||||
setPieces([]);
|
||||
setScore(0);
|
||||
setStatus("playing");
|
||||
setDropX(CONTAINER_WIDTH / 2);
|
||||
}, [dropMaxTier, setDropX]);
|
||||
|
||||
const handleDrop = useCallback(() => {
|
||||
if (statusRef.current !== "playing") return;
|
||||
const now = performance.now();
|
||||
if (now - lastDropAtRef.current < DROP_COOLDOWN_MS) return;
|
||||
lastDropAtRef.current = now;
|
||||
|
||||
const tier = nextTierRef.current;
|
||||
const radius = tierRadii[tier];
|
||||
const minX = WALL_THICKNESS + radius;
|
||||
const maxX = CONTAINER_WIDTH - WALL_THICKNESS - radius;
|
||||
const x = Math.min(maxX, Math.max(minX, dropXRef.current));
|
||||
// spawnPiece a des effets de bord (crée un vrai corps matter-js,
|
||||
// incrémente le compteur d'id) : appelé une seule fois ici, jamais
|
||||
// depuis l'intérieur du updater de setPieces, qui doit rester pur.
|
||||
const dropped = spawnPiece(tier, x, radius + 6);
|
||||
setPieces((current) => [...current, dropped]);
|
||||
|
||||
const upcoming = randomTier(DROP_MIN_TIER, dropMaxTier);
|
||||
nextTierRef.current = upcoming;
|
||||
setNextTier(upcoming);
|
||||
setDropX(dropXRef.current);
|
||||
}, [spawnPiece, setDropX, tierRadii, dropMaxTier]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: ReactPointerEvent) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
setDropX(event.clientX - rect.left);
|
||||
},
|
||||
[setDropX],
|
||||
);
|
||||
|
||||
function handlePointerUp() {
|
||||
handleDrop();
|
||||
}
|
||||
|
||||
// Clavier (ordinateur) : flèches pour viser, Espace/Entrée pour lâcher —
|
||||
// même logique que le support clavier ajouté au Vol d'Icare.
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.code === "ArrowLeft" || event.code === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const step = event.code === "ArrowLeft" ? -16 : 16;
|
||||
setDropX(dropXRef.current + step);
|
||||
} else if (event.code === "Space" || event.code === "Enter") {
|
||||
event.preventDefault();
|
||||
handleDrop();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleDrop, setDropX]);
|
||||
|
||||
useEffect(() => {
|
||||
const engine = Matter.Engine.create({ gravity: { x: 0, y: 1 } });
|
||||
engineRef.current = engine;
|
||||
|
||||
const floor = Matter.Bodies.rectangle(
|
||||
CONTAINER_WIDTH / 2,
|
||||
CONTAINER_HEIGHT + WALL_THICKNESS / 2,
|
||||
CONTAINER_WIDTH,
|
||||
WALL_THICKNESS,
|
||||
{ isStatic: true },
|
||||
);
|
||||
const leftWall = Matter.Bodies.rectangle(
|
||||
-WALL_THICKNESS / 2,
|
||||
CONTAINER_HEIGHT / 2,
|
||||
WALL_THICKNESS,
|
||||
CONTAINER_HEIGHT * 2,
|
||||
{ isStatic: true },
|
||||
);
|
||||
const rightWall = Matter.Bodies.rectangle(
|
||||
CONTAINER_WIDTH + WALL_THICKNESS / 2,
|
||||
CONTAINER_HEIGHT / 2,
|
||||
WALL_THICKNESS,
|
||||
CONTAINER_HEIGHT * 2,
|
||||
{ isStatic: true },
|
||||
);
|
||||
Matter.Composite.add(engine.world, [floor, leftWall, rightWall]);
|
||||
|
||||
// Les fusions sont détectées ici mais traitées après Engine.update (pas
|
||||
// pendant), pour ne jamais modifier le monde matter-js au milieu d'un
|
||||
// pas de simulation.
|
||||
Matter.Events.on(engine, "collisionStart", (event) => {
|
||||
for (const pair of event.pairs) {
|
||||
const metaA = pair.bodyA.plugin as BodyMeta | undefined;
|
||||
const metaB = pair.bodyB.plugin as BodyMeta | undefined;
|
||||
if (!metaA || !metaB) continue;
|
||||
if (metaA.tier !== metaB.tier) continue;
|
||||
if (mergingBodyIdsRef.current.has(pair.bodyA.id) || mergingBodyIdsRef.current.has(pair.bodyB.id)) continue;
|
||||
|
||||
mergingBodyIdsRef.current.add(pair.bodyA.id);
|
||||
mergingBodyIdsRef.current.add(pair.bodyB.id);
|
||||
pendingMergesRef.current.push({
|
||||
pieceIdA: metaA.pieceId,
|
||||
pieceIdB: metaB.pieceId,
|
||||
tier: metaA.tier,
|
||||
x: (pair.bodyA.position.x + pair.bodyB.position.x) / 2,
|
||||
y: (pair.bodyA.position.y + pair.bodyB.position.y) / 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let rafId: number;
|
||||
let lastFrame: number | null = null;
|
||||
let accumulator = 0;
|
||||
|
||||
function processPendingMerges() {
|
||||
if (pendingMergesRef.current.length === 0) return;
|
||||
const merges = pendingMergesRef.current;
|
||||
pendingMergesRef.current = [];
|
||||
|
||||
const removedIds: number[] = [];
|
||||
const spawned: Piece[] = [];
|
||||
|
||||
for (const merge of merges) {
|
||||
const bodyA = bodiesRef.current.get(merge.pieceIdA);
|
||||
const bodyB = bodiesRef.current.get(merge.pieceIdB);
|
||||
if (!bodyA || !bodyB) continue;
|
||||
|
||||
Matter.Composite.remove(engine.world, [bodyA, bodyB]);
|
||||
bodiesRef.current.delete(merge.pieceIdA);
|
||||
bodiesRef.current.delete(merge.pieceIdB);
|
||||
nodeRefs.current.delete(merge.pieceIdA);
|
||||
nodeRefs.current.delete(merge.pieceIdB);
|
||||
removedIds.push(merge.pieceIdA, merge.pieceIdB);
|
||||
|
||||
if (merge.tier < maxTier) {
|
||||
const newTier = merge.tier + 1;
|
||||
scoreRef.current += newTier;
|
||||
spawned.push(spawnPiece(newTier, merge.x, merge.y));
|
||||
} else {
|
||||
// Palier maximum déjà atteint : pas de pièce plus grande, juste
|
||||
// un bonus de score pour la fusion finale.
|
||||
scoreRef.current += maxTier * 2;
|
||||
}
|
||||
}
|
||||
|
||||
setScore(scoreRef.current);
|
||||
setPieces((current) => [...current.filter((p) => !removedIds.includes(p.id)), ...spawned]);
|
||||
}
|
||||
|
||||
// Renvoie les corps qui dépassent la ligne de danger (au repos, pas un
|
||||
// simple rebond) — utilisé à la fois pour déclencher/prolonger le
|
||||
// délai de grâce et pour savoir quelles pièces doivent clignoter.
|
||||
function overflowingBodyIds(): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
for (const [id, body] of bodiesRef.current) {
|
||||
const radius = body.circleRadius ?? 0;
|
||||
if (body.position.y - radius < DANGER_LINE_Y && body.speed < 0.5) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (lastFrame === null) lastFrame = now;
|
||||
const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS);
|
||||
lastFrame = now;
|
||||
|
||||
let overflowing = new Set<number>();
|
||||
|
||||
if (statusRef.current === "playing") {
|
||||
accumulator += frameDt;
|
||||
while (accumulator >= FIXED_DT_MS) {
|
||||
Matter.Engine.update(engine, FIXED_DT_MS);
|
||||
processPendingMerges();
|
||||
accumulator -= FIXED_DT_MS;
|
||||
}
|
||||
|
||||
overflowing = overflowingBodyIds();
|
||||
if (overflowing.size > 0) {
|
||||
if (dangerSinceRef.current === null) {
|
||||
dangerSinceRef.current = now;
|
||||
} else if (now - dangerSinceRef.current >= GAME_OVER_GRACE_MS) {
|
||||
statusRef.current = "dead";
|
||||
setStatus("dead");
|
||||
onFinishRef.current(scoreRef.current);
|
||||
}
|
||||
} else {
|
||||
dangerSinceRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// translate d'abord (positionne le centre), rotate ensuite (tourne
|
||||
// autour de ce centre, transform-origin par défaut) — sans ça les
|
||||
// pièces glissaient sans jamais rouler, ce qui les faisait paraître
|
||||
// figées/anguleuses plutôt que rondes. Le clignotement (classList,
|
||||
// pas de state React) prévient que le délai de grâce avant game over
|
||||
// est en cours pour cette pièce précisément.
|
||||
for (const [id, body] of bodiesRef.current) {
|
||||
const node = nodeRefs.current.get(id);
|
||||
if (!node) continue;
|
||||
const radius = body.circleRadius ?? 0;
|
||||
node.style.transform = `translate(${body.position.x - radius}px, ${body.position.y - radius}px) rotate(${body.angle}rad)`;
|
||||
node.classList.toggle("animate-danger-blink", overflowing.has(id));
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(frame);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
Matter.Events.off(engine, "collisionStart");
|
||||
Matter.Composite.clear(engine.world, false);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- monté une seule fois ; spawnPiece/maxTier sont stables tant que citizens.length ne change pas
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="font-heading text-2xl text-gold-bright">{score}</p>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
className="relative touch-none overflow-visible rounded-b-2xl border-x-4 border-b-4 border-gold/50 bg-ink-2/40 select-none"
|
||||
style={{ width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 border-t border-dashed border-oxblood/50"
|
||||
style={{ top: DANGER_LINE_Y }}
|
||||
/>
|
||||
|
||||
{/* Trait de visée vertical, sous la preview, jusqu'au fond du bac —
|
||||
positionné via le même setDropX que la preview. */}
|
||||
<div
|
||||
ref={(node) => {
|
||||
guideLineRef.current = node;
|
||||
if (node) node.style.transform = `translateX(${dropXRef.current}px)`;
|
||||
}}
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-dashed border-gold/40"
|
||||
/>
|
||||
|
||||
{pieces.map((piece) => {
|
||||
const citizen = citizens[piece.tier];
|
||||
const diameter = tierRadii[piece.tier] * 2;
|
||||
return (
|
||||
<div
|
||||
key={piece.id}
|
||||
ref={(node) => {
|
||||
if (node) nodeRefs.current.set(piece.id, node);
|
||||
}}
|
||||
className="pointer-events-none absolute top-0 left-0"
|
||||
>
|
||||
{/* La position/rotation (transform) de ce conteneur est
|
||||
pilotée impérativement à chaque frame depuis la boucle
|
||||
physique — l'animation d'apparition vit sur cet enfant,
|
||||
jamais sur le conteneur, pour ne pas entrer en conflit
|
||||
avec transform. Idem pour animate-danger-blink (ajoutée/
|
||||
retirée sur ce même conteneur par la boucle physique quand
|
||||
la pièce dépasse la ligne de danger). rounded-full ici :
|
||||
sans ça le halo de l'animation dessinait un carré visible
|
||||
autour du rond. animate-fade-in (pas animate-reveal) : ce
|
||||
dernier passe par 82% puis 104% avant de se stabiliser à
|
||||
100%, ce qui faisait paraître une pièce tout juste lâchée
|
||||
d'une taille différente de celle annoncée dans la
|
||||
preview. */}
|
||||
<div className="animate-fade-in rounded-full">
|
||||
<Avatar pseudo={citizen?.pseudo ?? "?"} avatarUrl={citizen?.avatar_url ?? null} size={diameter} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
ref={(node) => {
|
||||
previewRef.current = node;
|
||||
// Position initiale posée ici (callback de ref, exécuté au
|
||||
// montage du nœud) plutôt que dans un effet : un ref ne peut
|
||||
// pas être lu pendant le rendu, et setDropX (appelé depuis
|
||||
// resetGame/handleDrop) prend le relais pour toute mise à jour
|
||||
// ultérieure.
|
||||
if (node) {
|
||||
const radius = tierRadii[nextTierRef.current];
|
||||
node.style.transform = `translateX(${dropXRef.current - radius}px)`;
|
||||
}
|
||||
}}
|
||||
className="pointer-events-none absolute left-0"
|
||||
style={{ top: -(tierRadii[nextTier] * 2 + 12) }}
|
||||
>
|
||||
<Avatar
|
||||
pseudo={citizens[nextTier]?.pseudo ?? "?"}
|
||||
avatarUrl={citizens[nextTier]?.avatar_url ?? null}
|
||||
size={tierRadii[nextTier] * 2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === "dead" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-b-2xl bg-ink/70">
|
||||
<div className="marble-surface animate-reveal flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-6 py-4 text-center shadow-lg">
|
||||
<p className="text-xs tracking-wide text-text-mut uppercase">Score</p>
|
||||
<p className="font-heading text-3xl text-gold-bright">{score}</p>
|
||||
<button
|
||||
type="button"
|
||||
onPointerUp={(event) => event.stopPropagation()}
|
||||
onClick={resetGame}
|
||||
className="mt-1 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink"
|
||||
>
|
||||
Rejouer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-marble/50">
|
||||
Touche/clique pour viser et lâche — deux avatars de même taille fusionnent.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { CorneView } from "./corne-view";
|
||||
|
||||
export type MelonScoreEntry = {
|
||||
id: string;
|
||||
pseudo: string;
|
||||
avatar_url: string | null;
|
||||
best_score: number;
|
||||
};
|
||||
|
||||
export type MemberRow = {
|
||||
id: string;
|
||||
pseudo: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
export default async function CornePage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const [{ data: scores }, { data: members }, { data: settings }] = await Promise.all([
|
||||
supabase
|
||||
.from("melon_scores")
|
||||
.select("user_id, best_score")
|
||||
.order("best_score", { ascending: false }),
|
||||
supabase.from("profiles").select("id, pseudo, avatar_url, role").order("pseudo", { ascending: true }),
|
||||
supabase.from("settings").select("tribunal_date").eq("id", true).single(),
|
||||
]);
|
||||
|
||||
const memberById = new Map((members ?? []).map((member) => [member.id, member]));
|
||||
|
||||
const leaderboard: MelonScoreEntry[] = (scores ?? []).map((row) => ({
|
||||
id: row.user_id,
|
||||
pseudo: memberById.get(row.user_id)?.pseudo ?? "Un Citoyen",
|
||||
avatar_url: memberById.get(row.user_id)?.avatar_url ?? null,
|
||||
best_score: row.best_score,
|
||||
}));
|
||||
|
||||
// Un palier de taille par Citoyen (pas les Archontes) : chacun est
|
||||
// associé à une taille fixe pour toute la partie, voir corne-view.tsx.
|
||||
const citizens: MemberRow[] = (members ?? [])
|
||||
.filter((member) => member.role === "public")
|
||||
.map(({ id, pseudo, avatar_url }) => ({ id, pseudo, avatar_url }));
|
||||
|
||||
const isFrozen = Boolean(settings?.tribunal_date && new Date() >= new Date(settings.tribunal_date));
|
||||
const ownBestScore = leaderboard.find((entry) => entry.id === user.id)?.best_score ?? null;
|
||||
|
||||
return (
|
||||
<CorneView
|
||||
citizens={citizens}
|
||||
initialLeaderboard={leaderboard}
|
||||
initialOwnBestScore={ownBestScore}
|
||||
isFrozen={isFrozen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -165,6 +165,41 @@ body {
|
||||
animation: reveal-pop 700ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/* Simple fondu, sans mise à l'échelle — contrairement à reveal-pop,
|
||||
utilisé sur des pièces de La Corne d'Abondance dont la taille exacte
|
||||
doit être lisible dès la première frame (reveal-pop passe par 82% puis
|
||||
104% avant de se stabiliser à 100%, ce qui faisait paraître une pièce
|
||||
tout juste lâchée d'une taille différente de celle annoncée). */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 200ms ease-out both;
|
||||
}
|
||||
|
||||
/* Avertissement de débordement (La Corne d'Abondance) : clignote sur les
|
||||
pièces qui dépassent la ligne de danger pendant le délai de grâce
|
||||
avant game over, pour signaler que le compte à rebours est en cours. */
|
||||
@keyframes danger-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-danger-blink {
|
||||
animation: danger-blink 500ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Confettis de fin de partie (Le Vol d'Icare) : chute + rotation + fondu,
|
||||
dérive latérale et vitesse propres à chaque confetti via variables CSS. */
|
||||
@keyframes confetti-fall {
|
||||
|
||||
@@ -26,9 +26,13 @@ export function Avatar({
|
||||
}: {
|
||||
pseudo: string;
|
||||
avatarUrl: string | null;
|
||||
size?: keyof typeof SIZE_CLASSES;
|
||||
// Un nombre = taille en pixels arbitraire (ex. les paliers de La Corne
|
||||
// d'Abondance), en plus des tailles nommées existantes.
|
||||
size?: keyof typeof SIZE_CLASSES | number;
|
||||
}) {
|
||||
const sizeClass = SIZE_CLASSES[size];
|
||||
const isCustomSize = typeof size === "number";
|
||||
const sizeClass = isCustomSize ? "" : SIZE_CLASSES[size];
|
||||
const customStyle = isCustomSize ? { width: size, height: size, fontSize: size * 0.35 } : undefined;
|
||||
|
||||
if (avatarUrl) {
|
||||
// blob:/data: = aperçu local avant upload (cropper). next/image essaie
|
||||
@@ -42,6 +46,7 @@ export function Avatar({
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized={isLocalPreview}
|
||||
style={customStyle}
|
||||
className={`${sizeClass} rounded-full border-2 border-gold object-cover`}
|
||||
/>
|
||||
);
|
||||
@@ -49,6 +54,7 @@ export function Avatar({
|
||||
|
||||
return (
|
||||
<div
|
||||
style={customStyle}
|
||||
className={`${sizeClass} flex items-center justify-center rounded-full border-2 border-gold font-heading font-semibold text-marble ${fallbackColor(pseudo)}`}
|
||||
aria-hidden
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IconSword,
|
||||
IconPin,
|
||||
IconVote,
|
||||
IconHorn,
|
||||
} from "@/components/icons";
|
||||
|
||||
type MenuEntry = {
|
||||
@@ -95,6 +96,7 @@ export function Header({
|
||||
const entries: MenuEntry[] = [
|
||||
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
|
||||
{ href: "/icare", label: "Le Vol d'Icare", icon: <IconWings /> },
|
||||
{ href: "/corne", label: "La Corne d'Abondance", icon: <IconHorn /> },
|
||||
{ href: "/char", label: "Le Jeu de l'Agora", icon: <IconSword /> },
|
||||
{ href: "/mur", label: "Le Mur de la Honte", icon: <IconPin /> },
|
||||
{ href: "/urne", label: "L'Urne de l'Agora", icon: <IconVote /> },
|
||||
|
||||
@@ -291,3 +291,18 @@ export function IconWings({ className = base }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHorn({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<path
|
||||
d="M5 8c0-2.5 2-4 4.5-4 5 0 9.5 4.5 9.5 10.5 0 2-1.2 3-2.5 3-1.6 0-2-1.3-3.3-1.3-1.4 0-1.7 1.6-3.4 1.6-2.8 0-4.8-3.6-4.8-7.3 0-1 .2-1.8.5-2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="17" cy="18.5" r="1.6" fill="currentColor" stroke="none" />
|
||||
<circle cx="20" cy="15" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="20.5" cy="19.5" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Zone de jeu en pixels CSS fixes (pas de canvas redimensionné comme Icare
|
||||
// — un conteneur DOM de taille fixe suffit et évite toute logique de
|
||||
// "world scale").
|
||||
export const CONTAINER_WIDTH = 300;
|
||||
export const CONTAINER_HEIGHT = 420;
|
||||
export const WALL_THICKNESS = 20;
|
||||
|
||||
// Un palier par Citoyen existant (chacun associé à une taille fixe pour
|
||||
// toute la partie, voir melon-game.tsx) plutôt qu'un nombre de paliers
|
||||
// arbitraire : le nombre de tailles suit donc le nombre de Citoyens, pas
|
||||
// une constante fixe. Rayon minimal + croissance géométrique assez marquée
|
||||
// (×1.25 par palier — un ×1.19 précédent rendait deux paliers consécutifs
|
||||
// trop proches en taille : une fusion semblait "changer le visage d'une
|
||||
// pièce déjà posée" plutôt que "remplacer deux petites pièces par une
|
||||
// nettement plus grosse"), plafonnée à MAX_RADIUS pour rester dans
|
||||
// CONTAINER_WIDTH même avec beaucoup de Citoyens.
|
||||
const MIN_RADIUS = 15;
|
||||
const RADIUS_GROWTH = 1.25;
|
||||
const MAX_RADIUS = 110;
|
||||
|
||||
export function buildTierRadii(tierCount: number): number[] {
|
||||
const count = Math.max(1, tierCount);
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
Math.min(MAX_RADIUS, Math.round(MIN_RADIUS * RADIUS_GROWTH ** index)),
|
||||
);
|
||||
}
|
||||
|
||||
// Palier de départ pour la pièce à lâcher : parmi les plus petits
|
||||
// uniquement (sinon les premières fusions seraient trop rares/tardives).
|
||||
export const DROP_MIN_TIER = 0;
|
||||
export const DROP_MAX_TIER_CAP = 3;
|
||||
|
||||
export const DROP_COOLDOWN_MS = 450;
|
||||
|
||||
// Ligne de débordement (depuis le haut du bac) + délai de grâce avant game
|
||||
// over : évite qu'un simple rebond juste après une fusion ne déclenche une
|
||||
// fin de partie instantanée.
|
||||
export const DANGER_LINE_Y = CONTAINER_HEIGHT * 0.15;
|
||||
export const GAME_OVER_GRACE_MS = 5000;
|
||||
|
||||
export const FIXED_DT_MS = 1000 / 60;
|
||||
export const MAX_FRAME_DT_MS = 250;
|
||||
|
||||
export const MIN_SCORE = 0;
|
||||
export const MAX_SCORE = 1000000;
|
||||
@@ -11,6 +11,7 @@ const PROTECTED_PATHS = [
|
||||
"/char",
|
||||
"/mur",
|
||||
"/urne",
|
||||
"/corne",
|
||||
];
|
||||
// /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une
|
||||
// session (établie par le lien d'invitation) mais doit pouvoir rester sur
|
||||
|
||||
@@ -1419,3 +1419,149 @@ end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.cast_urn_vote(uuid, text) to authenticated;
|
||||
|
||||
-- 16. La Corne d'Abondance -------------------------------------------------------
|
||||
-- Mini-jeu de fusion façon "Watermelon Game" : des cercles tombent dans un
|
||||
-- bac, deux cercles de même palier qui se touchent fusionnent en un cercle
|
||||
-- plus gros, jusqu'à débordement. Au lieu de fruits, chaque cercle affiche
|
||||
-- l'avatar d'un membre tiré au hasard (décoratif, indépendant du palier).
|
||||
-- Même mécanique de gloires que Le Vol d'Icare : record personnel all-time,
|
||||
-- scores figés à la date du Tribunal, gloires du top 3 attribuées
|
||||
-- automatiquement (pg_cron, sans intervention d'un Archonte).
|
||||
|
||||
create table if not exists public.melon_scores (
|
||||
user_id uuid primary key references public.profiles (id) on delete cascade,
|
||||
best_score integer not null check (best_score between 0 and 1000000),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.melon_scores enable row level security;
|
||||
|
||||
-- Même modèle que icarus_scores : verrouillée en écriture, seule la RPC
|
||||
-- submit_melon_score() (SECURITY DEFINER) peut écrire.
|
||||
revoke insert, update, delete on public.melon_scores from authenticated, anon;
|
||||
grant select on public.melon_scores to authenticated;
|
||||
|
||||
drop policy if exists "melon_scores readable by authenticated" on public.melon_scores;
|
||||
create policy "melon_scores readable by authenticated"
|
||||
on public.melon_scores for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
-- Marqueur d'idempotence pour l'attribution automatique des gloires (un seul
|
||||
-- événement, comme icarus_points_awarded).
|
||||
alter table public.settings add column if not exists melon_points_awarded boolean not null default false;
|
||||
|
||||
-- Seul point d'entrée pour soumettre un score. Une fois la date du Tribunal
|
||||
-- atteinte, les scores sont figés : la RPC ne fait plus rien (retourne le
|
||||
-- record existant sans le modifier) plutôt que d'échouer bruyamment.
|
||||
create or replace function public.submit_melon_score(p_score integer)
|
||||
returns public.melon_scores
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_tribunal_date timestamptz;
|
||||
v_row public.melon_scores;
|
||||
begin
|
||||
if auth.uid() is null then
|
||||
raise exception 'authentication required';
|
||||
end if;
|
||||
|
||||
if p_score is null or p_score < 0 or p_score > 1000000 then
|
||||
raise exception 'invalid score';
|
||||
end if;
|
||||
|
||||
select tribunal_date into v_tribunal_date from public.settings where id = true;
|
||||
|
||||
if v_tribunal_date is not null and now() >= v_tribunal_date then
|
||||
select * into v_row from public.melon_scores where user_id = auth.uid();
|
||||
return v_row;
|
||||
end if;
|
||||
|
||||
insert into public.melon_scores (user_id, best_score, updated_at)
|
||||
values (auth.uid(), p_score, now())
|
||||
on conflict (user_id) do update
|
||||
set best_score = excluded.best_score,
|
||||
updated_at = now()
|
||||
where excluded.best_score > public.melon_scores.best_score;
|
||||
|
||||
select * into v_row from public.melon_scores where user_id = auth.uid();
|
||||
return v_row;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.submit_melon_score(integer) to authenticated;
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.melon_scores;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- Attribue les gloires du top 3 (ex-aequo inclus au même rang) dès que la
|
||||
-- date du Tribunal est atteinte ; no-op tant qu'elle n'est pas encore
|
||||
-- passée, et no-op définitif une fois déjà fait (melon_points_awarded).
|
||||
-- Volontairement pas de grant execute à authenticated : uniquement appelée
|
||||
-- par pg_cron ou depuis le SQL Editor.
|
||||
create or replace function public.award_melon_points_if_due()
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_tribunal_date timestamptz;
|
||||
v_already_awarded boolean;
|
||||
r record;
|
||||
v_points int;
|
||||
begin
|
||||
select tribunal_date, melon_points_awarded into v_tribunal_date, v_already_awarded
|
||||
from public.settings where id = true;
|
||||
|
||||
if v_tribunal_date is null or now() < v_tribunal_date or v_already_awarded then
|
||||
return;
|
||||
end if;
|
||||
|
||||
for r in
|
||||
with ranked as (
|
||||
select user_id, best_score,
|
||||
rank() over (order by best_score desc) as rnk
|
||||
from public.melon_scores
|
||||
)
|
||||
select * from ranked where rnk <= 3
|
||||
loop
|
||||
v_points := case r.rnk when 1 then 3 when 2 then 2 when 3 then 1 else 0 end;
|
||||
update public.profiles set points = points + v_points where id = r.user_id;
|
||||
insert into public.points_log (target_id, judge_id, delta, reason)
|
||||
values (r.user_id, null, v_points, 'La Corne d''Abondance — rang ' || r.rnk || ' au Tribunal');
|
||||
end loop;
|
||||
|
||||
update public.settings set melon_points_awarded = true where id = true;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase
|
||||
-- (Database → Extensions → "pg_cron" → Enable) — voir la même remarque à la
|
||||
-- section Icare. Les deux blocs ci-dessous n'échouent jamais bruyamment si
|
||||
-- l'extension n'est pas encore activée.
|
||||
do $$
|
||||
begin
|
||||
perform cron.unschedule('award-melon-points');
|
||||
exception
|
||||
when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
begin
|
||||
perform cron.schedule(
|
||||
'award-melon-points',
|
||||
'*/15 * * * *',
|
||||
$cron$select public.award_melon_points_if_due();$cron$
|
||||
);
|
||||
exception
|
||||
when others then
|
||||
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier l''attribution automatique des gloires de La Corne d''Abondance.';
|
||||
end $$;
|
||||
|
||||
Reference in New Issue
Block a user