358bed0c2e
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>
593 lines
24 KiB
TypeScript
593 lines
24 KiB
TypeScript
"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>
|
||
);
|
||
}
|