"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(null); const previewRef = useRef(null); const guideLineRef = useRef(null); const nodeRefs = useRef(new Map()); // 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("playing"); const [score, setScore] = useState(0); const [pieces, setPieces] = useState([]); 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("playing"); const scoreRef = useRef(0); const nextTierRef = useRef(nextTier); const secondTierRef = useRef(secondTier); const dropXRef = useRef(CONTAINER_WIDTH / 2); const engineRef = useRef(null); const bodiesRef = useRef(new Map()); const nextPieceIdRef = useRef(0); const lastDropAtRef = useRef(-Infinity); const pendingMergesRef = useRef([]); const mergingBodyIdsRef = useRef(new Set()); const dangerSinceRef = useRef(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 { const ids = new Set(); 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(); 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 (

{score}

{/* 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. */}
Puis
{/* 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. */}
{/* Trait de visée vertical, sous la preview, jusqu'au fond du bac — positionné via le même setDropX que la preview. */}
{ 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 (
{ 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. */}
); })}
{ 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) }} >
{status === "dead" && !prefersReducedMotion && } {status === "dead" && (

Score

{score}

)}

Touche/clique pour viser et lâche — deux avatars de même taille fusionnent.

{/* É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. */}

Ordre de fusion

{citizens.map((citizen, tier) => (
{tier < citizens.length - 1 && }
))}
); }