Ajoute La Course du Char : mini-jeu de course 2D quotidien
Build and deploy / deploy (push) Successful in 37s

Premier mini-jeu compétitif du Tribunal (/course) : piste 2D générée
de façon déterministe à partir de la date du jour (identique pour tout
le monde, jamais stockée côté serveur), pilotage au joystick tactile
virtuel, murs + obstacles qui ralentissent temporairement sans jamais
stopper la course, fantômes du top 3 du jour. Chacun rejoue autant
qu'il veut, seul le meilleur temps compte.

Moteur de jeu maison (src/lib/chariot/) : canvas + requestAnimationFrame,
sans dépendance externe, dans la continuité de l'existant (roulette).

Navigation : nouvelle entrée "La Course du Char" (IconChariot), route
protégée par le proxy comme le reste de l'app.
This commit is contained in:
Valentin ROBIN
2026-07-27 18:54:04 +02:00
parent 9f54b459f3
commit 6af56d2ade
15 changed files with 985 additions and 5 deletions
+323
View File
@@ -0,0 +1,323 @@
"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();
}
+118
View File
@@ -0,0 +1,118 @@
"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&apos;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&apos;a besoin
d&apos;intervenir.
</p>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
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&apos;emporte.
</p>
</div>
<CourseView today={today} initialTopRuns={topRuns} initialOwnBestMs={ownRun?.best_time_ms ?? null} />
</div>
);
}