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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user