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>
);
}
+2
View File
@@ -15,6 +15,7 @@ import {
IconLogout,
IconChevronDown,
IconCalendar,
IconChariot,
} from "@/components/icons";
type MenuEntry = {
@@ -91,6 +92,7 @@ export function Header({
const entries: MenuEntry[] = [
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> },
{ href: "/course", label: "La Course du Char", icon: <IconChariot /> },
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> },
+12
View File
@@ -241,3 +241,15 @@ export function IconBolt({ className = base }: IconProps) {
</svg>
);
}
export function IconChariot({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<circle cx="6.5" cy="18" r="2.3" />
<circle cx="17.5" cy="18" r="2.3" />
<path d="M6.5 15.7V10a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v5.7" strokeLinejoin="round" />
<path d="M8.5 8 11 3h3.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.5 12h5" strokeLinecap="round" />
</svg>
);
}
+34
View File
@@ -0,0 +1,34 @@
// Espace monde fixe (pas de caméra qui suit le char : toute la piste tient
// dans cet écran, mis à l'échelle au rendu selon la taille du canvas).
export const WORLD_WIDTH = 400;
export const WORLD_HEIGHT = 720;
export const CORRIDOR_HALF_WIDTH = 55;
export const CHARIOT_RADIUS = 14;
export const OBSTACLE_RADIUS = 16;
export const WAYPOINT_COUNT = 12;
export const WAYPOINT_STEP = 1; // unité arbitraire, la piste est remise à l'échelle après génération
export const MAX_TURN_RADIANS = (34 * Math.PI) / 180;
export const SPLINE_SAMPLES_PER_SEGMENT = 10;
export const OBSTACLE_COUNT = 8;
export const OBSTACLE_EDGE_BUFFER_RATIO = 0.08; // pas d'obstacle dans les 8% de début/fin
export const OBSTACLE_LATERAL_MARGIN = 12; // marge par rapport aux murs
export const MAX_SPEED = 230; // unités/seconde
export const MAX_ACCEL = 900; // unités/seconde², vitesse réelle -> vitesse cible
export const FRICTION_WHEN_IDLE = 500; // décélération quand le joystick est relâché
export const SLOWDOWN_SPEED_FACTOR = 0.4; // vitesse max plafonnée juste après un choc
export const SLOWDOWN_RECOVERY_MS = 900;
export const JOYSTICK_MAX_RADIUS = 70; // px, rayon de clamp du glissement tactile
export const FIXED_DT_MS = 1000 / 60;
export const MAX_FRAME_DT_MS = 250; // évite la "spirale de la mort" après un onglet en arrière-plan
export const GHOST_SAMPLE_INTERVAL_MS = 80;
export const MIN_TIME_MS = 2000;
export const MAX_TIME_MS = 300000;
+7
View File
@@ -0,0 +1,7 @@
export function formatRaceTime(ms: number): string {
const totalCentiseconds = Math.round(ms / 10);
const minutes = Math.floor(totalCentiseconds / 6000);
const seconds = Math.floor((totalCentiseconds % 6000) / 100);
const centiseconds = totalCentiseconds % 100;
return `${minutes}:${seconds.toString().padStart(2, "0")}.${centiseconds.toString().padStart(2, "0")}`;
}
+77
View File
@@ -0,0 +1,77 @@
export type Vec2 = { x: number; y: number };
export function add(a: Vec2, b: Vec2): Vec2 {
return { x: a.x + b.x, y: a.y + b.y };
}
export function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
export function scale(a: Vec2, s: number): Vec2 {
return { x: a.x * s, y: a.y * s };
}
export function length(a: Vec2): number {
return Math.hypot(a.x, a.y);
}
export function normalize(a: Vec2): Vec2 {
const len = length(a);
return len > 0 ? scale(a, 1 / len) : { x: 0, y: 0 };
}
export function dot(a: Vec2, b: Vec2): number {
return a.x * b.x + a.y * b.y;
}
export function rotate90(a: Vec2): Vec2 {
return { x: -a.y, y: a.x };
}
// Point le plus proche du point p sur le segment [a,b].
export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): { point: Vec2; t: number } {
const ab = sub(b, a);
const abLenSq = dot(ab, ab);
const t = abLenSq > 0 ? Math.max(0, Math.min(1, dot(sub(p, a), ab) / abLenSq)) : 0;
return { point: add(a, scale(ab, t)), t };
}
// Catmull-Rom uniforme : passe exactement par chaque point de contrôle,
// samplesPerSegment points intermédiaires entre chaque paire de points.
export function catmullRom(points: Vec2[], samplesPerSegment: number): Vec2[] {
if (points.length < 2) return points.slice();
const result: Vec2[] = [];
const at = (i: number) => points[Math.max(0, Math.min(points.length - 1, i))];
for (let i = 0; i < points.length - 1; i++) {
const p0 = at(i - 1);
const p1 = at(i);
const p2 = at(i + 1);
const p3 = at(i + 2);
for (let s = 0; s < samplesPerSegment; s++) {
const t = s / samplesPerSegment;
const t2 = t * t;
const t3 = t2 * t;
result.push({
x:
0.5 *
(2 * p1.x +
(-p0.x + p2.x) * t +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
y:
0.5 *
(2 * p1.y +
(-p0.y + p2.y) * t +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),
});
}
}
result.push(points[points.length - 1]);
return result;
}
+43
View File
@@ -0,0 +1,43 @@
import { GHOST_SAMPLE_INTERVAL_MS } from "./constants";
import type { GhostSample } from "./types";
// Ajoute un échantillon si assez de temps s'est écoulé depuis le dernier ;
// mutation en place, appelé à chaque frame depuis la boucle de jeu.
export function recordGhostSample(samples: GhostSample[], elapsedMs: number, x: number, y: number, a: number): void {
const last = samples[samples.length - 1];
if (!last || elapsedMs - last.t >= GHOST_SAMPLE_INTERVAL_MS) {
samples.push({ t: elapsedMs, x, y, a });
}
}
// Force un dernier échantillon exactement à l'arrivée (même hors cadence),
// pour que la relecture ne s'arrête jamais visiblement avant la ligne.
export function finishGhost(samples: GhostSample[], elapsedMs: number, x: number, y: number, a: number): void {
samples.push({ t: elapsedMs, x, y, a });
}
// Position interpolée d'un fantôme au temps écoulé donné ; reste figé à sa
// dernière position une fois son propre temps de course dépassé.
export function sampleGhostAt(path: GhostSample[], elapsedMs: number): GhostSample | null {
if (path.length === 0) return null;
if (elapsedMs <= path[0].t) return path[0];
const lastSample = path[path.length - 1];
if (elapsedMs >= lastSample.t) return lastSample;
for (let i = 0; i < path.length - 1; i++) {
const a = path[i];
const b = path[i + 1];
if (elapsedMs >= a.t && elapsedMs <= b.t) {
const span = b.t - a.t;
const t = span > 0 ? (elapsedMs - a.t) / span : 0;
return {
t: elapsedMs,
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
a: a.a + (b.a - a.a) * t,
};
}
}
return lastSample;
}
+112
View File
@@ -0,0 +1,112 @@
import {
CHARIOT_RADIUS,
FRICTION_WHEN_IDLE,
MAX_ACCEL,
MAX_SPEED,
SLOWDOWN_RECOVERY_MS,
SLOWDOWN_SPEED_FACTOR,
} from "./constants";
import { add, closestPointOnSegment, dot, length, normalize, scale, sub, type Vec2 } from "./geometry";
import type { ChariotState, InputVector, Track } from "./types";
function distanceToCenterline(point: Vec2, centerline: Vec2[]): { distance: number; closest: Vec2 } {
let best = Infinity;
let bestPoint = centerline[0];
for (let i = 0; i < centerline.length - 1; i++) {
const { point: candidate } = closestPointOnSegment(point, centerline[i], centerline[i + 1]);
const d = length(sub(point, candidate));
if (d < best) {
best = d;
bestPoint = candidate;
}
}
return { distance: best, closest: bestPoint };
}
// Vitesse max plafonnée après un choc : remonte progressivement de
// SLOWDOWN_SPEED_FACTOR à 1 sur SLOWDOWN_RECOVERY_MS — jamais d'arrêt net.
function speedCapFactor(lastCollisionAt: number | null, nowMs: number): number {
if (lastCollisionAt === null) return 1;
const sinceMs = nowMs - lastCollisionAt;
if (sinceMs >= SLOWDOWN_RECOVERY_MS) return 1;
const t = sinceMs / SLOWDOWN_RECOVERY_MS;
return SLOWDOWN_SPEED_FACTOR + (1 - SLOWDOWN_SPEED_FACTOR) * t;
}
export function createInitialState(track: Track): ChariotState {
return {
position: { ...track.start.position },
velocity: { x: 0, y: 0 },
heading: track.start.heading,
lastCollisionAt: null,
};
}
export function step(state: ChariotState, input: InputVector, track: Track, dtMs: number, nowMs: number): ChariotState {
const dt = dtMs / 1000;
const speedCap = speedCapFactor(state.lastCollisionAt, nowMs) * MAX_SPEED;
const inputMagnitude = Math.min(1, length(input));
const targetVelocity =
inputMagnitude > 0.001 ? scale(normalize(input), inputMagnitude * speedCap) : { x: 0, y: 0 };
const velocityDelta = sub(targetVelocity, state.velocity);
const maxStep = (inputMagnitude > 0.001 ? MAX_ACCEL : FRICTION_WHEN_IDLE) * dt;
const deltaLength = length(velocityDelta);
const appliedDelta = deltaLength > maxStep ? scale(normalize(velocityDelta), maxStep) : velocityDelta;
let velocity = add(state.velocity, appliedDelta);
let position = add(state.position, scale(velocity, dt));
let lastCollisionAt = state.lastCollisionAt;
// Murs : distance à la ligne centrale comparée à la demi-largeur du couloir.
const { distance, closest } = distanceToCenterline(position, track.centerline);
const wallLimit = track.halfWidth - CHARIOT_RADIUS;
if (distance > wallLimit) {
const outward = normalize(sub(position, closest));
position = add(closest, scale(outward, wallLimit));
const outwardComponent = dot(velocity, outward);
if (outwardComponent > 0) {
velocity = sub(velocity, scale(outward, outwardComponent));
}
lastCollisionAt = nowMs;
}
// Obstacles : cercles statiques.
for (const obstacle of track.obstacles) {
const toChariot = sub(position, obstacle.position);
const minDist = obstacle.radius + CHARIOT_RADIUS;
const dist = length(toChariot);
if (dist < minDist) {
const outward = dist > 0.001 ? normalize(toChariot) : { x: 1, y: 0 };
position = add(obstacle.position, scale(outward, minDist));
const outwardComponent = dot(velocity, outward);
if (outwardComponent < 0) {
velocity = sub(velocity, scale(outward, outwardComponent));
}
lastCollisionAt = nowMs;
}
}
const heading = length(velocity) > 5 ? Math.atan2(velocity.y, velocity.x) : state.heading;
return { position, velocity, heading, lastCollisionAt };
}
// Détecte le franchissement de la ligne d'arrivée entre deux positions
// consécutives (changement de signe de la position projetée sur la normale
// du segment d'arrivée, croisement vérifié dans les bornes du segment).
export function crossedFinish(prev: Vec2, next: Vec2, track: Track): boolean {
const { a, b } = track.finish;
const along = sub(b, a);
const normal = { x: -along.y, y: along.x };
const prevSide = dot(sub(prev, a), normal);
const nextSide = dot(sub(next, a), normal);
if (prevSide === 0 || Math.sign(prevSide) === Math.sign(nextSide)) return false;
const t = prevSide / (prevSide - nextSide);
const crossingPoint = add(prev, scale(sub(next, prev), t));
const alongLength = length(along);
const projected = dot(sub(crossingPoint, a), normalize(along));
return projected >= 0 && projected <= alongLength;
}
+23
View File
@@ -0,0 +1,23 @@
// PRNG déterministe (mulberry32) : même graine → même suite de nombres sur
// tous les appareils, condition nécessaire pour que la piste du jour soit
// identique pour tout le monde.
export function mulberry32(seed: number): () => number {
let a = seed;
return function random() {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// FNV-1a : transforme la date du jour ("2026-07-28") en graine numérique stable.
export function hashStringToSeed(value: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
+117
View File
@@ -0,0 +1,117 @@
import {
CORRIDOR_HALF_WIDTH,
MAX_TURN_RADIANS,
OBSTACLE_COUNT,
OBSTACLE_EDGE_BUFFER_RATIO,
OBSTACLE_LATERAL_MARGIN,
OBSTACLE_RADIUS,
SPLINE_SAMPLES_PER_SEGMENT,
WAYPOINT_COUNT,
WAYPOINT_STEP,
WORLD_HEIGHT,
WORLD_WIDTH,
} from "./constants";
import { add, catmullRom, normalize, rotate90, scale, sub, type Vec2 } from "./geometry";
import { hashStringToSeed, mulberry32 } from "./rng";
import type { Obstacle, Track } from "./types";
function generateRawWaypoints(rng: () => number): Vec2[] {
const points: Vec2[] = [{ x: 0, y: 0 }];
let heading = Math.PI / 2; // vers le bas (l'axe y de l'écran augmente vers le bas)
let current = points[0];
for (let i = 0; i < WAYPOINT_COUNT; i++) {
heading += (rng() - 0.5) * 2 * MAX_TURN_RADIANS;
current = add(current, {
x: Math.cos(heading) * WAYPOINT_STEP,
y: Math.sin(heading) * WAYPOINT_STEP,
});
points.push(current);
}
return points;
}
// Remet à l'échelle et recentre n'importe quelle marche aléatoire dans le
// rectangle jouable du monde (marge comprise) : garantit que la piste tient
// toujours dans l'écran fixe, quelle que soit la dérive de la marche aléatoire,
// sans avoir à régler un biais de rappel vers le centre au doigt mouillé.
function fitToWorld(points: Vec2[], margin: number): Vec2[] {
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const bboxWidth = Math.max(maxX - minX, 1e-6);
const bboxHeight = Math.max(maxY - minY, 1e-6);
const targetWidth = WORLD_WIDTH - margin * 2;
const targetHeight = WORLD_HEIGHT - margin * 2;
const scaleFactor = Math.min(targetWidth / bboxWidth, targetHeight / bboxHeight);
const bboxCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
const targetCenter = { x: WORLD_WIDTH / 2, y: WORLD_HEIGHT / 2 };
return points.map((p) => ({
x: (p.x - bboxCenter.x) * scaleFactor + targetCenter.x,
y: (p.y - bboxCenter.y) * scaleFactor + targetCenter.y,
}));
}
function placeObstacles(centerline: Vec2[], rng: () => number): Obstacle[] {
const n = centerline.length;
const startIndex = Math.floor(n * OBSTACLE_EDGE_BUFFER_RATIO);
const endIndex = Math.ceil(n * (1 - OBSTACLE_EDGE_BUFFER_RATIO));
const usable = Math.max(endIndex - startIndex, OBSTACLE_COUNT * 2);
const maxLateral = CORRIDOR_HALF_WIDTH - OBSTACLE_RADIUS - OBSTACLE_LATERAL_MARGIN;
const obstacles: Obstacle[] = [];
for (let i = 0; i < OBSTACLE_COUNT; i++) {
const slot = startIndex + Math.floor(((i + 0.5) / OBSTACLE_COUNT) * usable);
const jitter = Math.floor((rng() - 0.5) * (usable / OBSTACLE_COUNT) * 0.6);
const index = Math.max(1, Math.min(n - 2, slot + jitter));
const tangent = normalize(sub(centerline[index + 1], centerline[index - 1]));
const normal = rotate90(tangent);
const lateral = (rng() - 0.5) * 2 * maxLateral;
obstacles.push({
position: add(centerline[index], scale(normal, lateral)),
radius: OBSTACLE_RADIUS,
});
}
return obstacles;
}
// Fonction pure : la même dateKey (date du jour, Europe/Paris) produit
// toujours exactement la même piste, sans rien stocker côté serveur.
export function generateTrack(dateKey: string): Track {
const seed = hashStringToSeed(dateKey);
const rng = mulberry32(seed);
const margin = CORRIDOR_HALF_WIDTH + 20;
const waypoints = fitToWorld(generateRawWaypoints(rng), margin);
const centerline = catmullRom(waypoints, SPLINE_SAMPLES_PER_SEGMENT);
const obstacles = placeObstacles(centerline, rng);
const first = centerline[0];
const startHeading = Math.atan2(centerline[1].y - first.y, centerline[1].x - first.x);
const last = centerline[centerline.length - 1];
const beforeLast = centerline[centerline.length - 2];
const finishNormal = rotate90(normalize(sub(last, beforeLast)));
return {
seed,
centerline,
halfWidth: CORRIDOR_HALF_WIDTH,
obstacles,
start: { position: first, heading: startHeading },
finish: {
a: add(last, scale(finishNormal, CORRIDOR_HALF_WIDTH)),
b: add(last, scale(finishNormal, -CORRIDOR_HALF_WIDTH)),
},
world: { width: WORLD_WIDTH, height: WORLD_HEIGHT },
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { Vec2 } from "./geometry";
export type Obstacle = { position: Vec2; radius: number };
export type Track = {
seed: number;
centerline: Vec2[]; // ligne centrale dense, après lissage
halfWidth: number;
obstacles: Obstacle[];
start: { position: Vec2; heading: number };
finish: { a: Vec2; b: Vec2 };
world: { width: number; height: number };
};
// Direction * intensité (0..1) du joystick virtuel, espace écran.
export type InputVector = Vec2;
export type ChariotState = {
position: Vec2;
velocity: Vec2;
heading: number;
lastCollisionAt: number | null; // ms écoulés depuis le début de la course
};
export type GhostSample = { t: number; x: number; y: number; a: number };
+9 -1
View File
@@ -1,7 +1,15 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette", "/calendrier"];
const PROTECTED_PATHS = [
"/leaderboard",
"/profile",
"/admin",
"/journal",
"/roulette",
"/calendrier",
"/course",
];
// /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une
// session (établie par le lien d'invitation) mais doit pouvoir rester sur
// /signup pour finaliser mot de passe/pseudo/photo sans être renvoyé au