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