From 6af56d2ade69621a66a60c7f36880e451755d88f Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Mon, 27 Jul 2026 18:54:04 +0200 Subject: [PATCH] Ajoute La Course du Char : mini-jeu de course 2D quotidien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 18 +- src/app/course/chariot-game.tsx | 323 ++++++++++++++++++++++++++++++++ src/app/course/course-view.tsx | 118 ++++++++++++ src/app/course/page.tsx | 69 +++++++ src/components/header.tsx | 2 + src/components/icons.tsx | 12 ++ src/lib/chariot/constants.ts | 34 ++++ src/lib/chariot/format.ts | 7 + src/lib/chariot/geometry.ts | 77 ++++++++ src/lib/chariot/ghost.ts | 43 +++++ src/lib/chariot/physics.ts | 112 +++++++++++ src/lib/chariot/rng.ts | 23 +++ src/lib/chariot/track.ts | 117 ++++++++++++ src/lib/chariot/types.ts | 25 +++ src/lib/supabase/middleware.ts | 10 +- 15 files changed, 985 insertions(+), 5 deletions(-) create mode 100644 src/app/course/chariot-game.tsx create mode 100644 src/app/course/course-view.tsx create mode 100644 src/app/course/page.tsx create mode 100644 src/lib/chariot/constants.ts create mode 100644 src/lib/chariot/format.ts create mode 100644 src/lib/chariot/geometry.ts create mode 100644 src/lib/chariot/ghost.ts create mode 100644 src/lib/chariot/physics.ts create mode 100644 src/lib/chariot/rng.ts create mode 100644 src/lib/chariot/track.ts create mode 100644 src/lib/chariot/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5cde555..4a60a14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,14 +6,15 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol ## 1. État actuel -V1 à V5 sont livrées : +V1 à V6 sont livrées : - **V1** : comptes, profils, leaderboard statique. - **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin. - **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client. - **V4** : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur. - **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page **La Roulette** (tirage au sort animé), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement. +- **V6** : premier mini-jeu compétitif, **La Course du Char** (`/course`) — piste 2D générée aléatoirement chaque jour (identique pour tout le monde, calculée côté client à partir de la date), joystick tactile virtuel, murs/obstacles qui ralentissent temporairement, fantômes du top 3 du jour, seul mécanisme de points de tout le projet qui s'attribue **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte). -Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le jeu du Tribunal lui-même (Char, ostracisme, timer du Gardien), mode grand écran. +Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le reste du jeu du Tribunal (ostracisme, timer du Gardien — la Course du Char est livrée en V6), mode grand écran. --- @@ -56,6 +57,12 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo - `settings` : ligne unique (`id boolean primary key default true`, contrainte `check (id)`) — `tribunal_date`. - RLS : `SELECT` ouvert à tout authentifié ; `INSERT`/`UPDATE`/`DELETE` réservés au rôle `judge`, vérifié par policy (`exists (select 1 from profiles where id = auth.uid() and role = 'judge')`) — pas de trigger `BEFORE UPDATE` ici car il n'y a pas de colonne à protéger *dans une ligne par ailleurs modifiable par tous* (contrairement à `profiles`) : toute la table est verrouillée en écriture aux juges. +### `public.chariot_runs` / `public.chariot_race_closes` (La Course du Char) + +- `chariot_runs` : `user_id, race_date, best_time_ms, ghost_path (jsonb), updated_at` — clé primaire `(user_id, race_date)`, une seule ligne par joueur et par jour (le meilleur temps uniquement, pas l'historique des tentatives). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_chariot_run`. +- `chariot_race_closes` : table interne (aucune policy) qui marque les jours déjà clôturés, pour que la tâche planifiée reste idempotente. +- Pas de table `tracks` : la piste du jour est une fonction pure côté client (`src/lib/chariot/track.ts`, `generateTrack(dateKey)`), graine = date du jour Europe/Paris — jamais stockée côté serveur. + ### Colonnes sensibles : RLS + trigger, jamais confiance au client La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` : @@ -71,6 +78,8 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè - `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`). - `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client. - `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`. +- `submit_chariot_run(p_time_ms, p_ghost_path)` — authenticated, calcule `race_date` côté serveur (jamais fourni par le client), borne le temps (anti-triche minimal), n'écrase le meilleur temps du jour que s'il est strictement battu. +- `close_daily_chariot_race()` — **pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : classe la journée précédente, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur). --- @@ -89,12 +98,13 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè | `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email | | `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges | | `/roulette` | « La Roulette » — tirage au sort animé parmi les membres (ou Citoyens uniquement) | +| `/course` | « La Course du Char » — mini-jeu de course 2D (piste quotidienne, joystick tactile, fantômes du top 3), points attribués automatiquement | | `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges | | `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous | | `/profile` | Pseudo (verrouillable), photo, déconnexion | | `/admin` | « Le Conseil des Archontes » — juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement | -`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front). +`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier`, `/course` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front). Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée. @@ -143,6 +153,6 @@ Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code ## 8. Roadmap (pas encore codé) - **Éditions** : table `editions` (année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition. -- **Le jeu du Tribunal** : le Char (zone de pénalité), batailles de cul sec, ostracisme, timer du Gardien. (La Roulette est livrée en V5.) +- **Le jeu du Tribunal (suite)** : batailles de cul sec, ostracisme, timer du Gardien. (La Roulette est livrée en V5, la Course du Char en V6.) - **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur. - **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README). diff --git a/src/app/course/chariot-game.tsx b/src/app/course/chariot-game.tsx new file mode 100644 index 0000000..65b9771 --- /dev/null +++ b/src/app/course/chariot-game.tsx @@ -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(null); + const containerRef = useRef(null); + const [status, setStatus] = useState("idle"); + const [finishedMs, setFinishedMs] = useState(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(createInitialState(track)); + const inputRef = useRef({ x: 0, y: 0 }); + const pointerOriginRef = useRef<{ x: number; y: number; id: number } | null>(null); + const statusRef = useRef("idle"); + const startedAtRef = useRef(null); + const ghostPathRef = useRef([]); + 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 ( +
+
+ +
+ + {status === "idle" && ( +

+ Pose le doigt sur la piste et glisse pour diriger le char. +

+ )} + + {status === "finished" && finishedMs !== null && ( +
+

{formatRaceTime(finishedMs)}

+ +
+ )} +
+ ); +} + +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(); +} diff --git a/src/app/course/course-view.tsx b/src/app/course/course-view.tsx new file mode 100644 index 0000000..a3e6650 --- /dev/null +++ b/src/app/course/course-view.tsx @@ -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 ( +
+ {ranked.length > 0 && ( + formatRaceTime(member.best_time_ms)} /> + )} + + {ownBestMs !== null && ( +

+ Ton meilleur temps aujourd'hui :{" "} + {formatRaceTime(ownBestMs)} +

+ )} + + {/* 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. */} + + +

+ Le podium du jour reçoit des gloires automatiquement à minuit — aucun Archonte n'a besoin + d'intervenir. +

+
+ ); +} diff --git a/src/app/course/page.tsx b/src/app/course/page.tsx new file mode 100644 index 0000000..315089c --- /dev/null +++ b/src/app/course/page.tsx @@ -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 ( +
+
+

+ La Course du Char +

+

+ Une piste par jour, le meilleur temps l'emporte. +

+
+ +
+ ); +} diff --git a/src/components/header.tsx b/src/components/header.tsx index 5f34eda..3f707ac 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -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: }, { href: "/roulette", label: "La Roulette", icon: }, + { href: "/course", label: "La Course du Char", icon: }, { href: "/calendrier", label: "Le Calendrier des Dieux", icon: }, { href: "/journal", label: "Le Crieur", icon: }, { href: "/profile", label: "Mon profil", icon: }, diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 6cb396e..5470d8d 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -241,3 +241,15 @@ export function IconBolt({ className = base }: IconProps) { ); } + +export function IconChariot({ className = base }: IconProps) { + return ( + + + + + + + + ); +} diff --git a/src/lib/chariot/constants.ts b/src/lib/chariot/constants.ts new file mode 100644 index 0000000..99d7369 --- /dev/null +++ b/src/lib/chariot/constants.ts @@ -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; diff --git a/src/lib/chariot/format.ts b/src/lib/chariot/format.ts new file mode 100644 index 0000000..1e89427 --- /dev/null +++ b/src/lib/chariot/format.ts @@ -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")}`; +} diff --git a/src/lib/chariot/geometry.ts b/src/lib/chariot/geometry.ts new file mode 100644 index 0000000..7fe1d9d --- /dev/null +++ b/src/lib/chariot/geometry.ts @@ -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; +} diff --git a/src/lib/chariot/ghost.ts b/src/lib/chariot/ghost.ts new file mode 100644 index 0000000..79b32cf --- /dev/null +++ b/src/lib/chariot/ghost.ts @@ -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; +} diff --git a/src/lib/chariot/physics.ts b/src/lib/chariot/physics.ts new file mode 100644 index 0000000..54d3a8b --- /dev/null +++ b/src/lib/chariot/physics.ts @@ -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; +} diff --git a/src/lib/chariot/rng.ts b/src/lib/chariot/rng.ts new file mode 100644 index 0000000..da79906 --- /dev/null +++ b/src/lib/chariot/rng.ts @@ -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; +} diff --git a/src/lib/chariot/track.ts b/src/lib/chariot/track.ts new file mode 100644 index 0000000..7283f85 --- /dev/null +++ b/src/lib/chariot/track.ts @@ -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 }, + }; +} diff --git a/src/lib/chariot/types.ts b/src/lib/chariot/types.ts new file mode 100644 index 0000000..9093434 --- /dev/null +++ b/src/lib/chariot/types.ts @@ -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 }; diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index 9e9ddbe..110cf9c 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -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