Après retour d'expérience, la course de char (vue du dessus, piste générée par jour) laisse place à un Flappy Bird grec plus simple et plus lisible sur téléphone : Icare vole entre des colonnes de temple, touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro). Record personnel all-time (plus de piste quotidienne ni de fantômes) : les scores se figent dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées automatiquement via une tâche planifiée pg_cron, comme pour l'ancienne Course du Char. schema.sql nettoie explicitement l'ancienne Course du Char (tables, RPC, tâche planifiée) avant de poser le nouveau schéma, puisqu'elle avait déjà été appliquée en prod.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { computeRanksBy } from "@/lib/ranking";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { IcarusGame } from "./icarus-game";
|
||||
import type { IcarusScoreEntry } from "./page";
|
||||
|
||||
export function IcareView({
|
||||
initialLeaderboard,
|
||||
initialOwnBestScore,
|
||||
isFrozen,
|
||||
}: {
|
||||
initialLeaderboard: IcarusScoreEntry[];
|
||||
initialOwnBestScore: number | null;
|
||||
isFrozen: boolean;
|
||||
}) {
|
||||
const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
|
||||
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const supabase = createClient();
|
||||
const { data: scores } = await supabase
|
||||
.from("icarus_scores")
|
||||
.select("user_id, best_score")
|
||||
.order("best_score", { ascending: false });
|
||||
|
||||
const userIds = (scores ?? []).map((row) => row.user_id);
|
||||
const { data: profiles } =
|
||||
userIds.length > 0
|
||||
? await supabase.from("profiles").select("id, pseudo, avatar_url").in("id", userIds)
|
||||
: { data: [] as { id: string; pseudo: string; avatar_url: string | null }[] };
|
||||
const profileById = new Map((profiles ?? []).map((profile) => [profile.id, profile]));
|
||||
|
||||
setLeaderboard(
|
||||
(scores ?? []).map((row) => ({
|
||||
id: row.user_id,
|
||||
pseudo: profileById.get(row.user_id)?.pseudo ?? "Un Citoyen",
|
||||
avatar_url: profileById.get(row.user_id)?.avatar_url ?? null,
|
||||
best_score: row.best_score,
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
const channel = supabase
|
||||
.channel("icarus-scores")
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "icarus_scores" }, () => {
|
||||
refetch();
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
async function handleFinish(score: number) {
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score });
|
||||
if (!error && data) {
|
||||
setOwnBestScore(data.best_score);
|
||||
refetch();
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = useMemo(() => computeRanksBy(leaderboard, (a, b) => b.best_score - a.best_score), [leaderboard]);
|
||||
|
||||
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-text-marble">
|
||||
Ton record : <span className="text-gold-bright">{ownBestScore}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<IcarusGame onFinish={handleFinish} />
|
||||
|
||||
<p className="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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user