Files
tribunal-app/src/app/corne/melon-game.tsx
T
Valentin ROBIN 358bed0c2e Passe de polish visuel sur tout le site
Corrige deux incohérences trouvées en chemin : le compteur de passages dans le char en gold-bright sur marbre (même défaut de contraste déjà vu ailleurs), et le point du Crieur toujours rouge même pour un honneur positif. Réutilise les ornements existants mais jamais appelés (meander-divider entre le podium et le classement, DiamondDivider sur /profile avec une nouvelle convention de carte secondaire). Ajoute une ambiance discrète (fond qui respire très lentement, halo doré sur les cartes marbre, désactivés sous prefers-reduced-motion), étend les confettis et un nouveau son à Icare pour la parité avec la Corne d'Abondance, des loading.tsx sur les pages les plus consultées, et un petit ornement sur les états vides plutôt qu'un texte brut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:03:52 +02:00

593 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { playMergeSound } from "@/lib/melon/sound";
import { Confetti } from "@/components/confetti";
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 type MelonRunTelemetry = {
v: 1;
duration_ms: number;
actions: number;
merges: number;
};
export function MelonGame({
citizens,
onStart,
onFinish,
}: {
citizens: MemberRow[];
onStart: () => void;
onFinish: (score: number, run: MelonRunTelemetry) => 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));
// Palier annoncé après nextTier — juste informatif (pas de position à
// viser), pour voir venir deux coups à l'avance au lieu d'un seul.
const [secondTier, setSecondTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier));
const onStartRef = useRef(onStart);
useEffect(() => {
onStartRef.current = onStart;
}, [onStart]);
const onFinishRef = useRef(onFinish);
useEffect(() => {
onFinishRef.current = onFinish;
}, [onFinish]);
const statusRef = useRef<Status>("playing");
const scoreRef = useRef(0);
const nextTierRef = useRef(nextTier);
const secondTierRef = useRef(secondTier);
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);
// Pas Date.now() directement en argument de useRef (impur pendant le
// rendu, react-hooks/purity) : la vraie valeur est posée dans l'effet de
// montage juste en dessous — resetGame() (rejouer après une partie) la
// réécrit de toute façon à chaque nouvelle partie.
const runStartedAtRef = useRef(0);
useEffect(() => {
runStartedAtRef.current = Date.now();
onStartRef.current();
}, []);
const dropCountRef = useRef(0);
const mergeCountRef = useRef(0);
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];
// Pas de WALL_THICKNESS ici : les murs physiques sont déjà à ras des
// bords visuels du bac (leur face intérieure est exactement à x=0 et
// x=CONTAINER_WIDTH), donc seul le rayon de la pièce limite jusqu'où
// son centre peut aller. Ajouter WALL_THICKNESS en plus créait une
// marge fixe de 20px des deux côtés — disproportionnée pour les
// petits paliers, qui ne pouvaient plus s'approcher des bords.
const clamped = Math.min(CONTAINER_WIDTH - radius, Math.max(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;
runStartedAtRef.current = Date.now();
onStartRef.current();
dropCountRef.current = 0;
mergeCountRef.current = 0;
lastDropAtRef.current = -Infinity;
scoreRef.current = 0;
statusRef.current = "playing";
const firstNextTier = randomTier(DROP_MIN_TIER, dropMaxTier);
const firstSecondTier = randomTier(DROP_MIN_TIER, dropMaxTier);
nextTierRef.current = firstNextTier;
secondTierRef.current = firstSecondTier;
setNextTier(firstNextTier);
setSecondTier(firstSecondTier);
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];
// Même borne que setDropX (pas de WALL_THICKNESS en trop, voir
// là-bas) — dropXRef est déjà clampé de la même façon, ce recalcul ne
// fait que se protéger d'un dropXRef obtenu autrement (clavier, etc.).
const minX = radius;
const maxX = CONTAINER_WIDTH - 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);
dropCountRef.current += 1;
setPieces((current) => [...current, dropped]);
// La file glisse d'un cran : ce qui était annoncé en second devient la
// pièce à viser, et un nouveau palier est tiré pour la deuxième place.
const upcoming = secondTierRef.current;
const newSecond = randomTier(DROP_MIN_TIER, dropMaxTier);
nextTierRef.current = upcoming;
secondTierRef.current = newSecond;
setNextTier(upcoming);
setSecondTier(newSecond);
setDropX(dropXRef.current);
}, [spawnPiece, setDropX, tierRadii, dropMaxTier]);
const updateDropXFromEvent = useCallback(
(event: ReactPointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setDropX(event.clientX - rect.left);
},
[setDropX],
);
// Sur un tap sans glissement (mobile notamment), pointermove ne se
// déclenche pas forcément avant pointerup — sans repositionner ici aussi,
// le lâcher se faisait à l'ancienne position (celle d'une interaction
// précédente), pas là où le doigt vient de taper. onPointerDown en plus
// pour que le trait de visée bouge dès le contact, pas seulement au
// relâchement.
const handlePointerUp = useCallback(
(event: ReactPointerEvent) => {
updateDropXFromEvent(event);
handleDrop();
},
[updateDropXFromEvent, 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]);
mergeCountRef.current += 1;
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));
playMergeSound(newTier, maxTier);
} 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;
playMergeSound(maxTier, maxTier);
}
}
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. Le
// cercle doit avoir ENTIÈREMENT franchi la ligne (son bord bas,
// position.y + radius) plutôt que simplement y toucher (son bord haut,
// position.y - radius) : une pièce qui dépasse juste du sommet ne doit
// pas encore compter.
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, {
v: 1,
duration_ms: Math.max(0, Date.now() - runStartedAtRef.current),
actions: dropCountRef.current,
merges: mergeCountRef.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
}, []);
const prefersReducedMotion =
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return (
<div className="flex flex-col items-center gap-3">
<div className="flex items-center justify-between" style={{ width: CONTAINER_WIDTH }}>
<p className="font-heading text-2xl text-gold-bright">{score}</p>
{/* Deuxième pièce annoncée (après celle à viser) : juste
informatif, taille d'affichage fixe quel que soit le vrai
rayon du palier — pas de position à viser pour celle-ci. */}
<div className="flex items-center gap-1.5 opacity-70">
<span className="font-heading text-[0.6rem] tracking-wide text-marble/50 uppercase">Puis</span>
<Avatar
pseudo={citizens[secondTier]?.pseudo ?? "?"}
avatarUrl={citizens[secondTier]?.avatar_url ?? null}
size={32}
/>
</div>
</div>
{/* La bordure vit sur ce conteneur externe (sans largeur/hauteur
fixées : il s'ajuste automatiquement autour de l'enfant). La
bordure participant au box-sizing, la mettre directement sur le
conteneur aux coordonnées physiques (300×420) réduisait sa zone
intérieure réelle à 292×416 sans que la physique le sache — les
pièces pouvaient donc s'empiler jusqu'à x=300 alors que le cadre
visible s'arrêtait à 292, d'où le débordement à droite/en bas. */}
<div className="rounded-b-2xl border-x-4 border-b-4 border-gold/50">
<div
ref={containerRef}
onPointerDown={updateDropXFromEvent}
onPointerMove={updateDropXFromEvent}
onPointerUp={handlePointerUp}
className="relative touch-none overflow-visible rounded-b-2xl 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" && !prefersReducedMotion && <Confetti />}
{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>
</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>
{/* Échelle de repère : ordre de fusion complet, du plus petit au plus
grand — taille d'affichage proportionnelle au vrai rayon du
palier (mais réduite et plafonnée) pour que la progression se
voie d'un coup d'œil, sans dépendre du nombre de Citoyens.
flex-wrap (pas de scroll horizontal comme avant) : tout le monde
doit être visible sans interaction, l'intérêt d'une échelle de
repère est justement de tout voir d'un coup plutôt que de devoir
faire défiler. items-center (pas items-end) : aligner sur le bas
avec des tailles très variables donnait un décalage qui
grandissait avec la taille (la flèche, à décalage fixe, n'était
plus au bon endroit pour les gros ronds) — centrer chaque rond
sur son propre milieu reste symétrique quelle que soit sa
taille. Largeur plafonnée à max-w-sm (pas CONTAINER_WIDTH comme
avant) : la page (corne-view.tsx) est bien plus large que le bac
de jeu, la contrainte à 300px n'avait pas lieu d'être et forçait
le scroll pour rien. */}
<div className="marble-surface w-full max-w-sm rounded-lg border border-gold/25 p-3">
<p className="mb-2 text-center font-heading text-[0.65rem] tracking-[0.15em] text-text-mut uppercase">
Ordre de fusion
</p>
<div className="flex flex-wrap items-center justify-center gap-x-1.5 gap-y-3">
{citizens.map((citizen, tier) => (
<div key={citizen.id} className="flex items-center gap-1.5">
<Avatar
pseudo={citizen.pseudo}
avatarUrl={citizen.avatar_url}
size={Math.round(18 + (tierRadii[tier] / tierRadii[maxTier]) * 26)}
/>
{tier < citizens.length - 1 && <span className="text-xs text-gold/50"></span>}
</div>
))}
</div>
</div>
</div>
);
}