Passe Le Vol d'Icare en plein écran avec podium animé pour le top 3
Build and deploy / deploy (push) Successful in 35s

Le classement (dont un nouveau podium top 3, réutilisant celui de L'Agora)
vit maintenant dans une modale entièrement détachée de la mise en page du
jeu : une mise à jour Realtime du classement pendant une partie ne peut
plus redimensionner le canvas et casser la partie en cours. Le jeu occupe
tout l'écran disponible (contain avec letterboxing) au lieu d'une carte
fixe en haut de page. Corrige aussi le score du classement, en bleu au
lieu de doré (contraste insuffisant sur fond marbre clair).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-23 22:41:08 +02:00
parent e542b09807
commit 0dfd3e7e90
3 changed files with 121 additions and 73 deletions
+80 -33
View File
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium";
import { IconClose, IconTrophy } from "@/components/icons";
import { computeRanksBy } from "@/lib/ranking"; import { computeRanksBy } from "@/lib/ranking";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { IcarusGame } from "./icarus-game"; import { IcarusGame } from "./icarus-game";
@@ -18,6 +20,7 @@ export function IcareView({
}) { }) {
const [leaderboard, setLeaderboard] = useState(initialLeaderboard); const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore); const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
const [showRanking, setShowRanking] = useState(false);
const refetch = useCallback(async () => { const refetch = useCallback(async () => {
const supabase = createClient(); const supabase = createClient();
@@ -73,45 +76,89 @@ export function IcareView({
} }
} }
// Le classement vit dans une modale, complètement hors du flux de mise en
// page du jeu : sans ça, une mise à jour Realtime pendant une partie (le
// score d'un autre joueur qui change) redimensionnait le canvas et cassait
// la partie en cours.
const ranked = useMemo(() => computeRanksBy(leaderboard, (a, b) => b.best_score - a.best_score), [leaderboard]); 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 ( return (
<div className="flex flex-col gap-6"> <div className="relative flex flex-1 flex-col bg-ink">
{isFrozen && ( <div className="flex items-center justify-between px-4 py-2">
<p className="marble-surface rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble"> <h1 className="font-heading text-sm tracking-[0.15em] text-gold-bright uppercase">Le Vol d&apos;Icare</h1>
Les scores sont figés depuis le début du Tribunal. <button
</p> 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"
{ranked.length > 0 && ( >
<ol className="flex flex-col gap-1.5"> <IconTrophy className="h-4 w-4" />
{ranked.map((entry) => ( Classement
<li </button>
key={entry.id} </div>
className="marble-surface flex items-center gap-3 rounded-lg border border-gold/25 px-3 py-2 shadow-sm"
>
<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-gold-bright">{entry.best_score}</span>
</li>
))}
</ol>
)}
{ownBestScore !== null && (
<p className="text-center font-heading text-sm tracking-wide text-marble">
Ton record : <span className="text-gold-bright">{ownBestScore}</span>
</p>
)}
<IcarusGame onFinish={handleFinish} /> <IcarusGame onFinish={handleFinish} />
<p className="text-center text-xs text-marble/60"> {showRanking && (
{isFrozen <div className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm">
? "Les gloires du podium ont été attribuées automatiquement au début du Tribunal." <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">
: "Au début du Tribunal, les scores seront figés et le podium recevra des gloires automatiquement — aucun Archonte n'a besoin d'intervenir."} <div className="flex items-center justify-between border-b border-gold/20 px-4 py-3">
</p> <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">
{isFrozen && (
<p className="mb-4 rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble">
Les scores sont figés depuis le début du Tribunal.
</p>
)}
{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>
)}
{ownBestScore !== null && (
<p className="mt-4 text-center font-heading text-sm tracking-wide text-text-marble">
Ton record : <span className="text-sea">{ownBestScore}</span>
</p>
)}
<p className="mt-4 text-center text-xs text-text-mut">
{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>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+40 -31
View File
@@ -175,10 +175,19 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
// "Contain" dans le conteneur (largeur ET hauteur, contrairement à avant
// où seule la largeur comptait) : nécessaire en plein écran, où la
// hauteur disponible varie et n'est plus déduite de la largeur.
function resize() { function resize() {
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const cssWidth = container!.clientWidth; const availableWidth = container!.clientWidth;
const cssHeight = cssWidth * (VIEWPORT_HEIGHT / VIEWPORT_WIDTH); const availableHeight = container!.clientHeight;
let cssWidth = availableWidth;
let cssHeight = (cssWidth * VIEWPORT_HEIGHT) / VIEWPORT_WIDTH;
if (cssHeight > availableHeight) {
cssHeight = availableHeight;
cssWidth = (cssHeight * VIEWPORT_WIDTH) / VIEWPORT_HEIGHT;
}
canvas!.width = cssWidth * dpr; canvas!.width = cssWidth * dpr;
canvas!.height = cssHeight * dpr; canvas!.height = cssHeight * dpr;
canvas!.style.width = `${cssWidth}px`; canvas!.style.width = `${cssWidth}px`;
@@ -283,38 +292,38 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return ( return (
<div className="flex flex-col items-center gap-3"> <div
<div ref={containerRef}
ref={containerRef} onPointerDown={requestFlap}
onPointerDown={requestFlap} className="relative flex flex-1 touch-none items-center justify-center overflow-hidden bg-ink select-none"
className="relative w-full max-w-xs touch-none select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl" >
> <canvas ref={canvasRef} className="block" />
<canvas ref={canvasRef} className="block h-full w-full" />
{status === "dead" && !prefersReducedMotion && <Confetti />} {status === "dead" && !prefersReducedMotion && <Confetti />}
{status === "dead" && (
<div className="absolute inset-0 flex items-center justify-center bg-ink/50">
<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"
onPointerDown={(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>
{status === "idle" && ( {status === "idle" && (
<p className="text-center text-sm text-marble/60"> <div className="pointer-events-none absolute inset-x-0 bottom-10 flex justify-center px-6">
Touche l&apos;écran pour battre des ailes et t&apos;envoler entre les colonnes. <p className="marble-surface animate-reveal rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble shadow-lg">
</p> Touche l&apos;écran pour battre des ailes et t&apos;envoler entre les colonnes.
</p>
</div>
)}
{status === "dead" && (
<div className="absolute inset-0 flex items-center justify-center bg-ink/50">
<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"
onPointerDown={(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>
); );
+1 -9
View File
@@ -48,14 +48,6 @@ export default async function IcarePage() {
const ownBestScore = leaderboard.find((entry) => entry.id === user.id)?.best_score ?? null; const ownBestScore = leaderboard.find((entry) => entry.id === user.id)?.best_score ?? null;
return ( return (
<div className="mx-auto w-full max-w-2xl px-4 py-8"> <IcareView initialLeaderboard={leaderboard} initialOwnBestScore={ownBestScore} isFrozen={isFrozen} />
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">Le Vol d&apos;Icare</h1>
<p className="font-serif text-sm text-marble/60 italic">
Vole entre les colonnes, aussi loin que tes ailes de cire le permettent.
</p>
</div>
<IcareView initialLeaderboard={leaderboard} initialOwnBestScore={ownBestScore} isFrozen={isFrozen} />
</div>
); );
} }