Passe Le Vol d'Icare en plein écran avec podium animé pour le top 3
Build and deploy / deploy (push) Successful in 35s
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:
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { Podium } from "@/components/podium";
|
||||
import { IconClose, IconTrophy } from "@/components/icons";
|
||||
import { computeRanksBy } from "@/lib/ranking";
|
||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import { IcarusGame } from "./icarus-game";
|
||||
@@ -18,6 +20,7 @@ export function IcareView({
|
||||
}) {
|
||||
const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
|
||||
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
|
||||
const [showRanking, setShowRanking] = useState(false);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
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 podiumProfiles = ranked.filter((p) => p.rank <= 3);
|
||||
const rest = ranked.filter((p) => p.rank > 3);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{isFrozen && (
|
||||
<p className="marble-surface 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>
|
||||
)}
|
||||
|
||||
{ranked.length > 0 && (
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{ranked.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
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>
|
||||
)}
|
||||
<div className="relative flex flex-1 flex-col bg-ink">
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<h1 className="font-heading text-sm tracking-[0.15em] text-gold-bright uppercase">Le Vol d'Icare</h1>
|
||||
<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"
|
||||
>
|
||||
<IconTrophy className="h-4 w-4" />
|
||||
Classement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<IcarusGame onFinish={handleFinish} />
|
||||
|
||||
<p className="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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,10 +175,19 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
const ctx = canvas.getContext("2d");
|
||||
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() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = container!.clientWidth;
|
||||
const cssHeight = cssWidth * (VIEWPORT_HEIGHT / VIEWPORT_WIDTH);
|
||||
const availableWidth = container!.clientWidth;
|
||||
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!.height = cssHeight * dpr;
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onPointerDown={requestFlap}
|
||||
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 h-full w-full" />
|
||||
<div
|
||||
ref={containerRef}
|
||||
onPointerDown={requestFlap}
|
||||
className="relative flex flex-1 touch-none items-center justify-center overflow-hidden bg-ink select-none"
|
||||
>
|
||||
<canvas ref={canvasRef} className="block" />
|
||||
|
||||
{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 === "dead" && !prefersReducedMotion && <Confetti />}
|
||||
|
||||
{status === "idle" && (
|
||||
<p className="text-center text-sm text-marble/60">
|
||||
Touche l'écran pour battre des ailes et t'envoler entre les colonnes.
|
||||
</p>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-10 flex justify-center px-6">
|
||||
<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">
|
||||
Touche l'écran pour battre des ailes et t'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>
|
||||
);
|
||||
|
||||
@@ -48,14 +48,6 @@ export default async function IcarePage() {
|
||||
const ownBestScore = leaderboard.find((entry) => entry.id === user.id)?.best_score ?? null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">Le Vol d'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>
|
||||
<IcareView initialLeaderboard={leaderboard} initialOwnBestScore={ownBestScore} isFrozen={isFrozen} />
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user