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:
@@ -1,323 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FIXED_DT_MS, JOYSTICK_MAX_RADIUS, MAX_FRAME_DT_MS } from "@/lib/chariot/constants";
|
||||
import { formatRaceTime } from "@/lib/chariot/format";
|
||||
import { finishGhost, recordGhostSample, sampleGhostAt } from "@/lib/chariot/ghost";
|
||||
import { createInitialState, crossedFinish, step } from "@/lib/chariot/physics";
|
||||
import type { ChariotState, GhostSample, Track } from "@/lib/chariot/types";
|
||||
|
||||
type Status = "idle" | "racing" | "finished";
|
||||
|
||||
const GHOST_COLORS = ["#E7C560", "#C7CDD6", "#B08D57"];
|
||||
|
||||
function drawChariot(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
heading: number,
|
||||
color: string,
|
||||
alpha: number,
|
||||
) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(heading);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(15, 0);
|
||||
ctx.lineTo(-9, 8);
|
||||
ctx.lineTo(-9, -8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function ChariotGame({
|
||||
track,
|
||||
ghosts,
|
||||
onFinish,
|
||||
}: {
|
||||
track: Track;
|
||||
ghosts: GhostSample[][];
|
||||
onFinish: (timeMs: number, ghostPath: GhostSample[]) => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [finishedMs, setFinishedMs] = useState<number | null>(null);
|
||||
|
||||
const ghostsRef = useRef(ghosts);
|
||||
const onFinishRef = useRef(onFinish);
|
||||
useEffect(() => {
|
||||
ghostsRef.current = ghosts;
|
||||
}, [ghosts]);
|
||||
useEffect(() => {
|
||||
onFinishRef.current = onFinish;
|
||||
}, [onFinish]);
|
||||
|
||||
// État de jeu tenu en ref : évite un re-render React à 60 fps.
|
||||
const chariotRef = useRef<ChariotState>(createInitialState(track));
|
||||
const inputRef = useRef({ x: 0, y: 0 });
|
||||
const pointerOriginRef = useRef<{ x: number; y: number; id: number } | null>(null);
|
||||
const statusRef = useRef<Status>("idle");
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const ghostPathRef = useRef<GhostSample[]>([]);
|
||||
const scaleRef = useRef(1);
|
||||
|
||||
function resetRace() {
|
||||
chariotRef.current = createInitialState(track);
|
||||
statusRef.current = "idle";
|
||||
startedAtRef.current = null;
|
||||
ghostPathRef.current = [];
|
||||
setStatus("idle");
|
||||
setFinishedMs(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = container!.clientWidth;
|
||||
const cssHeight = cssWidth * (track.world.height / track.world.width);
|
||||
canvas!.width = cssWidth * dpr;
|
||||
canvas!.height = cssHeight * dpr;
|
||||
canvas!.style.width = `${cssWidth}px`;
|
||||
canvas!.style.height = `${cssHeight}px`;
|
||||
scaleRef.current = cssWidth / track.world.width;
|
||||
}
|
||||
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(container);
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
canvas!.setPointerCapture(event.pointerId);
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
pointerOriginRef.current = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
id: event.pointerId,
|
||||
};
|
||||
if (statusRef.current === "idle") {
|
||||
statusRef.current = "racing";
|
||||
setStatus("racing");
|
||||
startedAtRef.current = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const origin = pointerOriginRef.current;
|
||||
if (!origin || origin.id !== event.pointerId) return;
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
const dx = event.clientX - rect.left - origin.x;
|
||||
const dy = event.clientY - rect.top - origin.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const clamped = Math.min(1, dist / JOYSTICK_MAX_RADIUS);
|
||||
inputRef.current = dist > 0.001 ? { x: (dx / dist) * clamped, y: (dy / dist) * clamped } : { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
if (pointerOriginRef.current?.id === event.pointerId) {
|
||||
pointerOriginRef.current = null;
|
||||
inputRef.current = { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", handlePointerDown);
|
||||
canvas.addEventListener("pointermove", handlePointerMove);
|
||||
canvas.addEventListener("pointerup", handlePointerUp);
|
||||
canvas.addEventListener("pointercancel", handlePointerUp);
|
||||
|
||||
let rafId: number;
|
||||
let lastFrame: number | null = null;
|
||||
let accumulator = 0;
|
||||
|
||||
function frame(now: number) {
|
||||
if (lastFrame === null) lastFrame = now;
|
||||
const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS);
|
||||
lastFrame = now;
|
||||
|
||||
if (statusRef.current === "racing" && startedAtRef.current !== null) {
|
||||
accumulator += frameDt;
|
||||
const elapsedMs = now - startedAtRef.current;
|
||||
|
||||
while (accumulator >= FIXED_DT_MS) {
|
||||
const prevPosition = { ...chariotRef.current.position };
|
||||
chariotRef.current = step(chariotRef.current, inputRef.current, track, FIXED_DT_MS, elapsedMs);
|
||||
accumulator -= FIXED_DT_MS;
|
||||
|
||||
if (crossedFinish(prevPosition, chariotRef.current.position, track)) {
|
||||
statusRef.current = "finished";
|
||||
finishGhost(
|
||||
ghostPathRef.current,
|
||||
elapsedMs,
|
||||
chariotRef.current.position.x,
|
||||
chariotRef.current.position.y,
|
||||
chariotRef.current.heading,
|
||||
);
|
||||
setStatus("finished");
|
||||
setFinishedMs(elapsedMs);
|
||||
onFinishRef.current(elapsedMs, ghostPathRef.current);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (statusRef.current === "racing") {
|
||||
recordGhostSample(
|
||||
ghostPathRef.current,
|
||||
elapsedMs,
|
||||
chariotRef.current.position.x,
|
||||
chariotRef.current.position.y,
|
||||
chariotRef.current.heading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render(ctx!, canvas!, track, chariotRef.current, ghostsRef.current, startedAtRef.current, statusRef.current, pointerOriginRef.current, inputRef.current);
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(frame);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
canvas.removeEventListener("pointerdown", handlePointerDown);
|
||||
canvas.removeEventListener("pointermove", handlePointerMove);
|
||||
canvas.removeEventListener("pointerup", handlePointerUp);
|
||||
canvas.removeEventListener("pointercancel", handlePointerUp);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [track]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full max-w-sm select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
|
||||
>
|
||||
<canvas ref={canvasRef} className="block h-full w-full touch-none" />
|
||||
</div>
|
||||
|
||||
{status === "idle" && (
|
||||
<p className="text-center text-sm text-text-mut">
|
||||
Pose le doigt sur la piste et glisse pour diriger le char.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === "finished" && finishedMs !== null && (
|
||||
<div className="marble-surface flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-4 py-3 text-center shadow-lg">
|
||||
<p className="font-heading text-lg tracking-wide text-gold-bright">{formatRaceTime(finishedMs)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetRace}
|
||||
className="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>
|
||||
);
|
||||
}
|
||||
|
||||
function render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
track: Track,
|
||||
chariot: ChariotState,
|
||||
ghosts: GhostSample[][],
|
||||
startedAt: number | null,
|
||||
status: Status,
|
||||
pointerOrigin: { x: number; y: number; id: number } | null,
|
||||
input: { x: number; y: number },
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const worldScale = cssWidth / track.world.width;
|
||||
const elapsedMs = startedAt !== null ? performance.now() - startedAt : 0;
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// -- Espace "monde" : piste, obstacles, fantômes, char.
|
||||
ctx.save();
|
||||
ctx.scale(worldScale, worldScale);
|
||||
ctx.clearRect(0, 0, track.world.width, track.world.height);
|
||||
|
||||
ctx.fillStyle = "#0A1B33";
|
||||
ctx.fillRect(0, 0, track.world.width, track.world.height);
|
||||
|
||||
ctx.lineWidth = track.halfWidth * 2;
|
||||
ctx.strokeStyle = "#F4ECD8";
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
track.centerline.forEach((point, index) => {
|
||||
if (index === 0) ctx.moveTo(point.x, point.y);
|
||||
else ctx.lineTo(point.x, point.y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = "#C9A227";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(track.finish.a.x, track.finish.a.y);
|
||||
ctx.lineTo(track.finish.b.x, track.finish.b.y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "#A5342A";
|
||||
for (const obstacle of track.obstacles) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(obstacle.position.x, obstacle.position.y, obstacle.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (startedAt !== null) {
|
||||
ghosts.forEach((path, index) => {
|
||||
const sample = sampleGhostAt(path, elapsedMs);
|
||||
if (!sample) return;
|
||||
drawChariot(ctx, sample.x, sample.y, sample.a, GHOST_COLORS[index % GHOST_COLORS.length], 0.45);
|
||||
});
|
||||
}
|
||||
|
||||
drawChariot(ctx, chariot.position.x, chariot.position.y, chariot.heading, "#E7C560", 1);
|
||||
ctx.restore();
|
||||
|
||||
// -- Espace "écran" (pixels CSS) : joystick tactile et chrono.
|
||||
if (pointerOrigin) {
|
||||
const nubX = pointerOrigin.x + input.x * JOYSTICK_MAX_RADIUS;
|
||||
const nubY = pointerOrigin.y + input.y * JOYSTICK_MAX_RADIUS;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.strokeStyle = "#E7C560";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pointerOrigin.x, pointerOrigin.y, JOYSTICK_MAX_RADIUS, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = "#E7C560";
|
||||
ctx.beginPath();
|
||||
ctx.arc(nubX, nubY, 16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (status === "racing" && startedAt !== null) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#F4ECD8";
|
||||
ctx.font = "600 20px Cinzel, serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(formatRaceTime(elapsedMs), cssWidth / 2, 30);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Podium } from "@/components/podium";
|
||||
import { formatRaceTime } from "@/lib/chariot/format";
|
||||
import { generateTrack } from "@/lib/chariot/track";
|
||||
import type { GhostSample } from "@/lib/chariot/types";
|
||||
import { computeRanksBy } from "@/lib/ranking";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { ChariotGame } from "./chariot-game";
|
||||
import type { ChariotLeaderboardEntry } from "./page";
|
||||
|
||||
export function CourseView({
|
||||
today,
|
||||
initialTopRuns,
|
||||
initialOwnBestMs,
|
||||
}: {
|
||||
today: string;
|
||||
initialTopRuns: ChariotLeaderboardEntry[];
|
||||
initialOwnBestMs: number | null;
|
||||
}) {
|
||||
const [topRuns, setTopRuns] = useState(initialTopRuns);
|
||||
const [ownBestMs, setOwnBestMs] = useState(initialOwnBestMs);
|
||||
|
||||
const track = useMemo(() => generateTrack(today), [today]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const supabase = createClient();
|
||||
const { data: runs } = await supabase
|
||||
.from("chariot_runs")
|
||||
.select("user_id, best_time_ms, ghost_path")
|
||||
.eq("race_date", today)
|
||||
.order("best_time_ms", { ascending: true })
|
||||
.limit(3);
|
||||
|
||||
const userIds = (runs ?? []).map((run) => run.user_id);
|
||||
const { data: runProfiles } =
|
||||
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((runProfiles ?? []).map((profile) => [profile.id, profile]));
|
||||
|
||||
setTopRuns(
|
||||
(runs ?? []).map((run) => ({
|
||||
id: run.user_id,
|
||||
pseudo: profileById.get(run.user_id)?.pseudo ?? "Un Citoyen",
|
||||
avatar_url: profileById.get(run.user_id)?.avatar_url ?? null,
|
||||
best_time_ms: run.best_time_ms,
|
||||
ghost_path: run.ghost_path,
|
||||
})),
|
||||
);
|
||||
}, [today]);
|
||||
|
||||
// Comme Le Calendrier des Dieux : on ré-interroge tout à chaque changement
|
||||
// plutôt que de fusionner le payload localement — le client ne voit que le
|
||||
// top 3 du jour, pas tout le classement, donc une fusion partielle ne
|
||||
// suffirait pas à recalculer correctement qui en fait partie.
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
const channel = supabase
|
||||
.channel("chariot-runs")
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_runs" }, () => {
|
||||
refetch();
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
async function handleFinish(timeMs: number, ghostPath: GhostSample[]) {
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase.rpc("submit_chariot_run", {
|
||||
p_time_ms: Math.round(timeMs),
|
||||
p_ghost_path: ghostPath,
|
||||
});
|
||||
if (!error && data) {
|
||||
setOwnBestMs(data.best_time_ms);
|
||||
refetch();
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = useMemo(
|
||||
() => computeRanksBy(topRuns, (a, b) => a.best_time_ms - b.best_time_ms),
|
||||
[topRuns],
|
||||
);
|
||||
|
||||
const ghosts = useMemo(
|
||||
() => topRuns.map((run) => (Array.isArray(run.ghost_path) ? (run.ghost_path as GhostSample[]) : [])),
|
||||
[topRuns],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{ranked.length > 0 && (
|
||||
<Podium profiles={ranked} renderValue={(member) => formatRaceTime(member.best_time_ms)} />
|
||||
)}
|
||||
|
||||
{ownBestMs !== null && (
|
||||
<p className="text-center font-heading text-sm tracking-wide text-text-marble">
|
||||
Ton meilleur temps aujourd'hui :{" "}
|
||||
<span className="text-gold-bright">{formatRaceTime(ownBestMs)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* key=track.seed : force un remontage complet (état de jeu neuf) si la
|
||||
piste change (nouveau jour), plutôt qu'un effet qui réinitialiserait
|
||||
l'état depuis l'intérieur du composant. */}
|
||||
<ChariotGame key={track.seed} track={track} ghosts={ghosts} onFinish={handleFinish} />
|
||||
|
||||
<p className="text-center text-xs text-text-mut">
|
||||
Le podium du jour reçoit des gloires automatiquement à minuit — aucun Archonte n'a besoin
|
||||
d'intervenir.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { CourseView } from "./course-view";
|
||||
|
||||
export type ChariotLeaderboardEntry = {
|
||||
id: string;
|
||||
pseudo: string;
|
||||
avatar_url: string | null;
|
||||
best_time_ms: number;
|
||||
ghost_path: unknown;
|
||||
};
|
||||
|
||||
export default async function CoursePage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// Même formule que Le Calendrier des Dieux : la date du jour en
|
||||
// Europe/Paris, seule source de vérité pour "quelle piste" et "quel jour".
|
||||
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||
|
||||
const { data: runs } = await supabase
|
||||
.from("chariot_runs")
|
||||
.select("user_id, best_time_ms, ghost_path")
|
||||
.eq("race_date", today)
|
||||
.order("best_time_ms", { ascending: true })
|
||||
.limit(3);
|
||||
|
||||
const userIds = (runs ?? []).map((run) => run.user_id);
|
||||
const { data: runProfiles } =
|
||||
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((runProfiles ?? []).map((profile) => [profile.id, profile]));
|
||||
|
||||
const topRuns: ChariotLeaderboardEntry[] = (runs ?? []).map((run) => ({
|
||||
id: run.user_id,
|
||||
pseudo: profileById.get(run.user_id)?.pseudo ?? "Un Citoyen",
|
||||
avatar_url: profileById.get(run.user_id)?.avatar_url ?? null,
|
||||
best_time_ms: run.best_time_ms,
|
||||
ghost_path: run.ghost_path,
|
||||
}));
|
||||
|
||||
const { data: ownRun } = await supabase
|
||||
.from("chariot_runs")
|
||||
.select("best_time_ms")
|
||||
.eq("race_date", today)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
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">
|
||||
La Course du Char
|
||||
</h1>
|
||||
<p className="font-serif text-sm text-marble/60 italic">
|
||||
Une piste par jour, le meilleur temps l'emporte.
|
||||
</p>
|
||||
</div>
|
||||
<CourseView today={today} initialTopRuns={topRuns} initialOwnBestMs={ownRun?.best_time_ms ?? null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
COLUMN_GAP,
|
||||
COLUMN_GAP_MARGIN,
|
||||
COLUMN_SPACING,
|
||||
COLUMN_WIDTH,
|
||||
FIXED_DT_MS,
|
||||
FLAP_IMPULSE,
|
||||
FORWARD_SPEED,
|
||||
GROUND_Y,
|
||||
ICARUS_RADIUS,
|
||||
ICARUS_X,
|
||||
MAX_FRAME_DT_MS,
|
||||
TILT_MAX_RADIANS,
|
||||
VIEWPORT_HEIGHT,
|
||||
VIEWPORT_WIDTH,
|
||||
} from "@/lib/icarus/constants";
|
||||
import { collidesWithColumn, createInitialIcarus, hasHitGround, stepIcarus } from "@/lib/icarus/physics";
|
||||
import type { Column, IcarusState } from "@/lib/icarus/types";
|
||||
|
||||
type Status = "idle" | "flying" | "dead";
|
||||
|
||||
function randomGapCenter(): number {
|
||||
const minCenter = COLUMN_GAP / 2 + COLUMN_GAP_MARGIN;
|
||||
const maxCenter = GROUND_Y - COLUMN_GAP / 2 - COLUMN_GAP_MARGIN;
|
||||
return minCenter + Math.random() * Math.max(0, maxCenter - minCenter);
|
||||
}
|
||||
|
||||
function drawIcarus(ctx: CanvasRenderingContext2D, y: number, tilt: number) {
|
||||
ctx.save();
|
||||
ctx.translate(ICARUS_X, y);
|
||||
ctx.rotate(tilt);
|
||||
|
||||
ctx.fillStyle = "#F4ECD8";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-2, -2);
|
||||
ctx.lineTo(-ICARUS_RADIUS * 2.2, -ICARUS_RADIUS * 1.3);
|
||||
ctx.lineTo(-ICARUS_RADIUS * 0.5, ICARUS_RADIUS * 0.5);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(2, -2);
|
||||
ctx.lineTo(ICARUS_RADIUS * 2.2, -ICARUS_RADIUS * 1.3);
|
||||
ctx.lineTo(ICARUS_RADIUS * 0.5, ICARUS_RADIUS * 0.5);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#E7C560";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, ICARUS_RADIUS, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawColumnShaft(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
top: number,
|
||||
height: number,
|
||||
capAtBottom: boolean,
|
||||
) {
|
||||
if (height <= 0) return;
|
||||
|
||||
ctx.fillStyle = "#F4ECD8";
|
||||
ctx.fillRect(x, top, COLUMN_WIDTH, height);
|
||||
|
||||
ctx.strokeStyle = "#C9A227";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const lineX = x + (COLUMN_WIDTH / 4) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineX, top);
|
||||
ctx.lineTo(lineX, top + height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const capHeight = Math.min(10, height);
|
||||
const capY = capAtBottom ? top + height - capHeight : top;
|
||||
ctx.fillStyle = "#E7C560";
|
||||
ctx.fillRect(x - 4, capY, COLUMN_WIDTH + 8, capHeight);
|
||||
}
|
||||
|
||||
function drawColumn(ctx: CanvasRenderingContext2D, column: Column) {
|
||||
const gapTop = column.gapCenterY - COLUMN_GAP / 2;
|
||||
const gapBottom = column.gapCenterY + COLUMN_GAP / 2;
|
||||
drawColumnShaft(ctx, column.x, 0, gapTop, true);
|
||||
drawColumnShaft(ctx, column.x, gapBottom, GROUND_Y - gapBottom, false);
|
||||
}
|
||||
|
||||
export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [score, setScore] = useState(0);
|
||||
|
||||
const onFinishRef = useRef(onFinish);
|
||||
useEffect(() => {
|
||||
onFinishRef.current = onFinish;
|
||||
}, [onFinish]);
|
||||
|
||||
const icarusRef = useRef<IcarusState>(createInitialIcarus());
|
||||
const columnsRef = useRef<Column[]>([]);
|
||||
const statusRef = useRef<Status>("idle");
|
||||
const scoreRef = useRef(0);
|
||||
const flapRequestedRef = useRef(false);
|
||||
|
||||
function resetGame() {
|
||||
icarusRef.current = createInitialIcarus();
|
||||
columnsRef.current = [];
|
||||
scoreRef.current = 0;
|
||||
statusRef.current = "idle";
|
||||
setStatus("idle");
|
||||
setScore(0);
|
||||
}
|
||||
|
||||
function requestFlap() {
|
||||
flapRequestedRef.current = true;
|
||||
if (statusRef.current === "idle") {
|
||||
statusRef.current = "flying";
|
||||
setStatus("flying");
|
||||
} else if (statusRef.current === "dead") {
|
||||
resetGame();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = container!.clientWidth;
|
||||
const cssHeight = cssWidth * (VIEWPORT_HEIGHT / VIEWPORT_WIDTH);
|
||||
canvas!.width = cssWidth * dpr;
|
||||
canvas!.height = cssHeight * dpr;
|
||||
canvas!.style.width = `${cssWidth}px`;
|
||||
canvas!.style.height = `${cssHeight}px`;
|
||||
}
|
||||
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(container);
|
||||
|
||||
let rafId: number;
|
||||
let lastFrame: number | null = null;
|
||||
let accumulator = 0;
|
||||
|
||||
function frame(now: number) {
|
||||
if (lastFrame === null) lastFrame = now;
|
||||
const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS);
|
||||
lastFrame = now;
|
||||
|
||||
if (statusRef.current === "flying") {
|
||||
accumulator += frameDt;
|
||||
|
||||
while (accumulator >= FIXED_DT_MS) {
|
||||
if (flapRequestedRef.current) {
|
||||
icarusRef.current = { ...icarusRef.current, velocityY: FLAP_IMPULSE };
|
||||
}
|
||||
icarusRef.current = stepIcarus(icarusRef.current, FIXED_DT_MS);
|
||||
|
||||
for (const column of columnsRef.current) {
|
||||
column.x -= FORWARD_SPEED * (FIXED_DT_MS / 1000);
|
||||
if (!column.scored && column.x + COLUMN_WIDTH < ICARUS_X - ICARUS_RADIUS) {
|
||||
column.scored = true;
|
||||
scoreRef.current += 1;
|
||||
setScore(scoreRef.current);
|
||||
}
|
||||
}
|
||||
columnsRef.current = columnsRef.current.filter((c) => c.x + COLUMN_WIDTH > 0);
|
||||
|
||||
const last = columnsRef.current[columnsRef.current.length - 1];
|
||||
if (!last || last.x < VIEWPORT_WIDTH - COLUMN_SPACING) {
|
||||
columnsRef.current.push({
|
||||
x: last ? last.x + COLUMN_SPACING : VIEWPORT_WIDTH,
|
||||
gapCenterY: randomGapCenter(),
|
||||
scored: false,
|
||||
});
|
||||
}
|
||||
|
||||
const hitColumn = columnsRef.current.some((c) => collidesWithColumn(icarusRef.current.y, c));
|
||||
if (hasHitGround(icarusRef.current) || hitColumn) {
|
||||
statusRef.current = "dead";
|
||||
setStatus("dead");
|
||||
onFinishRef.current(scoreRef.current);
|
||||
break;
|
||||
}
|
||||
|
||||
accumulator -= FIXED_DT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
flapRequestedRef.current = false;
|
||||
render(ctx!, canvas!, icarusRef.current, columnsRef.current, statusRef.current, scoreRef.current);
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(frame);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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>
|
||||
|
||||
<p className="text-center font-heading text-2xl text-gold-bright">{score}</p>
|
||||
|
||||
{status === "idle" && (
|
||||
<p className="text-center text-sm text-text-mut">
|
||||
Touche l'écran pour battre des ailes et t'envoler entre les colonnes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === "dead" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetGame}
|
||||
className="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>
|
||||
);
|
||||
}
|
||||
|
||||
function render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
icarus: IcarusState,
|
||||
columns: Column[],
|
||||
status: Status,
|
||||
score: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const worldScale = cssWidth / VIEWPORT_WIDTH;
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.save();
|
||||
ctx.scale(worldScale, worldScale);
|
||||
|
||||
ctx.clearRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
ctx.fillStyle = "#0A1B33";
|
||||
ctx.fillRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
|
||||
|
||||
for (const column of columns) {
|
||||
drawColumn(ctx, column);
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#2A2116";
|
||||
ctx.fillRect(0, GROUND_Y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT - GROUND_Y);
|
||||
|
||||
const tilt = Math.max(-1, Math.min(1, icarus.velocityY / 300)) * TILT_MAX_RADIANS;
|
||||
drawIcarus(ctx, icarus.y, tilt);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
if (status === "flying") {
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#F4ECD8";
|
||||
ctx.font = "700 28px Cinzel, serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(String(score), cssWidth / 2, 44);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { IcareView } from "./icare-view";
|
||||
|
||||
export type IcarusScoreEntry = {
|
||||
id: string;
|
||||
pseudo: string;
|
||||
avatar_url: string | null;
|
||||
best_score: number;
|
||||
};
|
||||
|
||||
export default async function IcarePage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
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: scoreProfiles } =
|
||||
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((scoreProfiles ?? []).map((profile) => [profile.id, profile]));
|
||||
|
||||
const leaderboard: IcarusScoreEntry[] = (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,
|
||||
}));
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from("settings")
|
||||
.select("tribunal_date")
|
||||
.eq("id", true)
|
||||
.single();
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user