Compare commits

..

4 Commits

Author SHA1 Message Date
Valentin ROBIN 6af56d2ade 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.
2026-07-27 18:54:04 +02:00
Valentin ROBIN 9f54b459f3 Généralise Podium et computeRanks au-delà des points
computeRanksBy(items, comparator) remplace la logique de classement
"ex-aequo partagent le rang" en la rendant générique ; computeRanks
devient un simple appel avec le comparateur points desc, sans rien
changer pour l'appelant existant. Podium accepte maintenant un prop
renderValue (+ valueLabel optionnel) à la place du "points/gloires" en
dur, pour pouvoir afficher un classement par temps (Course du Char)
avec le même composant que le classement par points.
2026-07-27 18:53:54 +02:00
Valentin ROBIN 777d9ebaea Ajoute le schéma de La Course du Char (tables, RPC, clôture auto)
- chariot_runs : meilleur temps + tracé fantôme par joueur et par jour,
  verrouillée comme points_log (écriture uniquement via RPC).
- submit_chariot_run() : calcule la date côté serveur, borne le temps,
  n'écrase que si strictement meilleur.
- close_daily_chariot_race() + chariot_race_closes : clôture la journée
  précédente et attribue les gloires du top 3 automatiquement, sans
  intervention d'un Archonte — planifiée via pg_cron (à activer une
  fois côté Dashboard Supabase).
- points_log.judge_id devient nullable (un point auto n'a pas de juge) ;
  Le Crieur affiche "Le Tribunal" pour ces décrets-là.
2026-07-27 18:53:45 +02:00
Valentin ROBIN f462cff8a7 Retire le bouton "Nouveau round" du Conseil des Archontes
Un seul round sur toute la semaine : plus besoin de pouvoir
réinitialiser le repère de classement depuis l'admin. La logique des
flèches de progression (previous_rank, reset_rank_reference) reste en
place, simplement plus rien ne la déclenche.
2026-07-27 18:53:22 +02:00
22 changed files with 1192 additions and 62 deletions
+14 -4
View File
@@ -6,14 +6,15 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
## 1. État actuel ## 1. État actuel
V1 à V5 sont livrées : V1 à V6 sont livrées :
- **V1** : comptes, profils, leaderboard statique. - **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. - **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. - **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. - **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. - **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`. - `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. - 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 ### 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()` : 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`). - `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. - `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`. - `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 | | `/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 | | `/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) | | `/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 | | `/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 | | `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
| `/profile` | Pseudo (verrouillable), photo, déconnexion | | `/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 | | `/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. 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é) ## 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. - **É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. - **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur.
- **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README). - **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README).
+5 -9
View File
@@ -1,7 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { AdminMembersTable } from "./admin-members-table"; import { AdminMembersTable } from "./admin-members-table";
import { ResetRoundButton } from "./reset-round-button";
export default async function AdminPage() { export default async function AdminPage() {
const supabase = await createClient(); const supabase = await createClient();
@@ -32,14 +31,11 @@ export default async function AdminPage() {
return ( return (
<div className="mx-auto w-full max-w-3xl px-4 py-8"> <div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3"> <div className="mb-6">
<div> <h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase"> Le Conseil des Archontes
Le Conseil des Archontes </h1>
</h1> <p className="font-serif text-sm text-marble/60 italic">Administration des Citoyens</p>
<p className="font-serif text-sm text-marble/60 italic">Administration des Citoyens</p>
</div>
<ResetRoundButton />
</div> </div>
<AdminMembersTable members={members ?? []} currentUserId={user.id} /> <AdminMembersTable members={members ?? []} currentUserId={user.id} />
</div> </div>
-29
View File
@@ -1,29 +0,0 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function ResetRoundButton() {
const [state, setState] = useState<"idle" | "loading" | "done">("idle");
async function handleClick() {
setState("loading");
const supabase = createClient();
const { error } = await supabase.rpc("reset_rank_reference");
setState(error ? "idle" : "done");
if (!error) {
setTimeout(() => setState("idle"), 2000);
}
}
return (
<button
type="button"
onClick={handleClick}
disabled={state === "loading"}
className="marble-surface rounded-md border border-gold/40 px-3 py-1.5 text-sm font-medium text-text-marble shadow-sm transition hover:bg-gold/10 disabled:opacity-50"
>
{state === "loading" ? "…" : state === "done" ? "Repère mis à jour ✓" : "Nouveau round (réinitialiser le repère)"}
</button>
);
}
+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>
);
}
+3 -2
View File
@@ -6,7 +6,7 @@ import { createClient } from "@/lib/supabase/client";
type Entry = { type Entry = {
id: number; id: number;
target_id: string; target_id: string;
judge_id: string; judge_id: string | null;
delta: number; delta: number;
reason: string | null; reason: string | null;
created_at: string; created_at: string;
@@ -55,7 +55,8 @@ export function JournalView({
return ( return (
<ol className="flex flex-col gap-2"> <ol className="flex flex-col gap-2">
{entries.map((entry) => { {entries.map((entry) => {
const judgePseudo = pseudoById[entry.judge_id] ?? "Un Archonte"; const judgePseudo =
entry.judge_id === null ? "Le Tribunal" : (pseudoById[entry.judge_id] ?? "Un Archonte");
const targetPseudo = pseudoById[entry.target_id] ?? "un Citoyen"; const targetPseudo = pseudoById[entry.target_id] ?? "un Citoyen";
const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`; const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`;
return ( return (
+2
View File
@@ -89,6 +89,8 @@ export function LeaderboardView({
<div> <div>
<Podium <Podium
profiles={ranked.filter((p) => p.rank <= 3)} profiles={ranked.filter((p) => p.rank <= 3)}
renderValue={(member) => member.points}
valueLabel="gloires"
isJudge={isJudge} isJudge={isJudge}
onApplyDelta={applyOptimisticDelta} onApplyDelta={applyOptimisticDelta}
/> />
+2
View File
@@ -15,6 +15,7 @@ import {
IconLogout, IconLogout,
IconChevronDown, IconChevronDown,
IconCalendar, IconCalendar,
IconChariot,
} from "@/components/icons"; } from "@/components/icons";
type MenuEntry = { type MenuEntry = {
@@ -91,6 +92,7 @@ export function Header({
const entries: MenuEntry[] = [ const entries: MenuEntry[] = [
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> }, { href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> }, { 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: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> }, { href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> }, { href: "/profile", label: "Mon profil", icon: <IconPerson /> },
+12
View File
@@ -241,3 +241,15 @@ export function IconBolt({ className = base }: IconProps) {
</svg> </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>
);
}
+14 -7
View File
@@ -1,9 +1,10 @@
import type { ReactNode } from "react";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { JudgePointControls } from "@/components/judge-point-controls"; import { JudgePointControls } from "@/components/judge-point-controls";
import { LaurelWreath } from "@/components/laurel-wreath"; import { LaurelWreath } from "@/components/laurel-wreath";
import { toRoman, type RankedProfile } from "@/lib/ranking"; import { toRoman, type Ranked } from "@/lib/ranking";
type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number }; type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null };
const SLOT_STYLES: Record< const SLOT_STYLES: Record<
number, number,
@@ -14,16 +15,20 @@ const SLOT_STYLES: Record<
3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-bronze", delay: "0ms" }, 3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-bronze", delay: "0ms" },
}; };
export function Podium({ export function Podium<T extends PodiumProfile>({
profiles, profiles,
renderValue,
valueLabel,
isJudge = false, isJudge = false,
onApplyDelta, onApplyDelta,
}: { }: {
profiles: RankedProfile<PodiumProfile>[]; profiles: Ranked<T>[];
renderValue: (member: Ranked<T>) => ReactNode;
valueLabel?: string;
isJudge?: boolean; isJudge?: boolean;
onApplyDelta?: (id: string, delta: number) => void; onApplyDelta?: (id: string, delta: number) => void;
}) { }) {
const byRank = new Map<number, RankedProfile<PodiumProfile>[]>(); const byRank = new Map<number, Ranked<T>[]>();
for (const profile of profiles) { for (const profile of profiles) {
const group = byRank.get(profile.rank) ?? []; const group = byRank.get(profile.rank) ?? [];
group.push(profile); group.push(profile);
@@ -77,9 +82,11 @@ export function Podium({
isFirst ? "text-xl sm:text-2xl" : "text-base sm:text-lg" isFirst ? "text-xl sm:text-2xl" : "text-base sm:text-lg"
}`} }`}
> >
{member.points} {renderValue(member)}
</span> </span>
<span className="-mt-1 text-[0.65rem] text-text-mut">gloires</span> {valueLabel && (
<span className="-mt-1 text-[0.65rem] text-text-mut">{valueLabel}</span>
)}
{isJudge && onApplyDelta && ( {isJudge && onApplyDelta && (
<JudgePointControls <JudgePointControls
memberId={member.id} memberId={member.id}
+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 };
+16 -10
View File
@@ -1,21 +1,27 @@
export type RankedProfile<T extends { points: number }> = T & { rank: number }; export type Ranked<T> = T & { rank: number };
export type RankedProfile<T extends { points: number }> = Ranked<T>;
// Classement "compétition" : les ex-aequo partagent le même rang, et le // Classement "compétition" générique : les ex-aequo (selon le comparateur)
// rang suivant saute en conséquence (1, 2, 2, 4 — pas 1, 2, 2, 3). // partagent le même rang, et le rang suivant saute en conséquence
export function computeRanks<T extends { points: number }>(profiles: T[]): RankedProfile<T>[] { // (1, 2, 2, 4 — pas 1, 2, 2, 3).
const sorted = [...profiles].sort((a, b) => b.points - a.points); export function computeRanksBy<T>(items: T[], compare: (a: T, b: T) => number): Ranked<T>[] {
let lastPoints: number | null = null; const sorted = [...items].sort(compare);
let lastItem: T | null = null;
let lastRank = 0; let lastRank = 0;
return sorted.map((profile, index) => { return sorted.map((item, index) => {
if (lastPoints === null || profile.points !== lastPoints) { if (lastItem === null || compare(item, lastItem) !== 0) {
lastRank = index + 1; lastRank = index + 1;
lastPoints = profile.points; lastItem = item;
} }
return { ...profile, rank: lastRank }; return { ...item, rank: lastRank };
}); });
} }
export function computeRanks<T extends { points: number }>(profiles: T[]): RankedProfile<T>[] {
return computeRanksBy(profiles, (a, b) => b.points - a.points);
}
const ROMAN_DIGITS: [number, string][] = [ const ROMAN_DIGITS: [number, string][] = [
[10, "X"], [10, "X"],
[9, "IX"], [9, "IX"],
+9 -1
View File
@@ -1,7 +1,15 @@
import { createServerClient } from "@supabase/ssr"; import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server"; 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 // /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 // session (établie par le lien d'invitation) mais doit pouvoir rester sur
// /signup pour finaliser mot de passe/pseudo/photo sans être renvoyé au // /signup pour finaliser mot de passe/pseudo/photo sans être renvoyé au
+167
View File
@@ -484,3 +484,170 @@ end $$;
-- --
-- (Récupérer l'uuid via Authentication → Users, ou : -- (Récupérer l'uuid via Authentication → Users, ou :
-- select id, pseudo from public.profiles;) -- select id, pseudo from public.profiles;)
-- 11. La Course du Char --------------------------------------------------------
-- Mini-jeu : course de char en vue du dessus, une piste générée aléatoirement
-- par jour (calculée côté client à partir de la date Europe/Paris, jamais
-- stockée ici), chacun rejoue autant qu'il veut, seul le meilleur temps du
-- jour compte. Les points du jour sont attribués automatiquement (pas
-- d'Archonte impliqué) par une tâche planifiée pg_cron — seul mécanisme de
-- points de tout le projet qui n'attend pas de décision d'un juge.
-- Un point attribué automatiquement n'a pas de juge : judge_id devient
-- optionnel (le Crieur affiche alors "Le Tribunal" à la place d'un pseudo).
alter table public.points_log alter column judge_id drop not null;
create table if not exists public.chariot_runs (
user_id uuid not null references public.profiles (id) on delete cascade,
race_date date not null,
best_time_ms integer not null check (best_time_ms between 2000 and 300000),
ghost_path jsonb not null default '[]'::jsonb,
updated_at timestamptz not null default now(),
primary key (user_id, race_date)
);
create index if not exists chariot_runs_race_date_idx
on public.chariot_runs (race_date, best_time_ms);
alter table public.chariot_runs enable row level security;
-- Même modèle que points_log : verrouillée en écriture, seule la RPC
-- submit_chariot_run() (SECURITY DEFINER) peut écrire.
revoke insert, update, delete on public.chariot_runs from authenticated, anon;
grant select on public.chariot_runs to authenticated;
drop policy if exists "chariot_runs readable by authenticated" on public.chariot_runs;
create policy "chariot_runs readable by authenticated"
on public.chariot_runs for select
to authenticated
using (true);
-- Seul point d'entrée pour soumettre un temps : la date du jour est calculée
-- côté serveur (jamais fournie par le client, pour ne pas pouvoir soumettre
-- "pour" un autre jour), le temps est borné grossièrement (anti-triche
-- minimal, suffisant pour un jeu entre amis — pas de vérification fine par
-- piste), et on n'écrase le temps existant que s'il est strictement meilleur.
create or replace function public.submit_chariot_run(p_time_ms integer, p_ghost_path jsonb default '[]'::jsonb)
returns public.chariot_runs
language plpgsql
security definer
set search_path = public
as $$
declare
v_race_date date;
v_row public.chariot_runs;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
if p_time_ms is null or p_time_ms < 2000 or p_time_ms > 300000 then
raise exception 'invalid time_ms';
end if;
v_race_date := (now() at time zone 'Europe/Paris')::date;
insert into public.chariot_runs (user_id, race_date, best_time_ms, ghost_path, updated_at)
values (auth.uid(), v_race_date, p_time_ms, coalesce(p_ghost_path, '[]'::jsonb), now())
on conflict (user_id, race_date) do update
set best_time_ms = excluded.best_time_ms,
ghost_path = excluded.ghost_path,
updated_at = now()
where excluded.best_time_ms < public.chariot_runs.best_time_ms;
-- On resélectionne toujours plutôt que de dépendre du RETURNING de
-- l'upsert : si la clause WHERE bloque la mise à jour (temps pas
-- meilleur), l'INSERT ne retourne aucune ligne. On renvoie dans tous les
-- cas le meilleur temps courant du joueur, qu'il vienne d'être battu ou non.
select * into v_row from public.chariot_runs
where user_id = auth.uid() and race_date = v_race_date;
return v_row;
end;
$$;
grant execute on function public.submit_chariot_run(integer, jsonb) to authenticated;
do $$
begin
alter publication supabase_realtime add table public.chariot_runs;
exception
when duplicate_object then null;
end $$;
-- Marqueur interne : quelles journées ont déjà été clôturées (idempotence de
-- la tâche planifiée, qui tourne toutes les 15 minutes plutôt qu'une seule
-- fois à minuit pile — le changement d'heure CET/CEST décalerait un horaire
-- UTC fixe). Table strictement interne : aucune policy, jamais lue/écrite
-- par le client.
create table if not exists public.chariot_race_closes (
race_date date primary key,
closed_at timestamptz not null default now()
);
alter table public.chariot_race_closes enable row level security;
revoke all on public.chariot_race_closes from authenticated, anon;
-- Clôture de la journée précédente (Europe/Paris) : classe chariot_runs,
-- attribue les gloires du top 3 (ex-aequo inclus au même rang), journalise
-- dans points_log avec judge_id = null, puis marque la date comme close.
-- No-op si déjà clôturée. Volontairement pas de grant execute à
-- authenticated : uniquement appelée par pg_cron ou depuis le SQL Editor.
create or replace function public.close_daily_chariot_race()
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_target_date date := ((now() at time zone 'Europe/Paris')::date - 1);
r record;
v_points int;
begin
if exists (select 1 from public.chariot_race_closes where race_date = v_target_date) then
return;
end if;
for r in
with ranked as (
select user_id, best_time_ms,
rank() over (order by best_time_ms asc) as rnk
from public.chariot_runs
where race_date = v_target_date
)
select * from ranked where rnk <= 3
loop
v_points := case r.rnk when 1 then 3 when 2 then 2 when 3 then 1 else 0 end;
update public.profiles set points = points + v_points where id = r.user_id;
insert into public.points_log (target_id, judge_id, delta, reason)
values (r.user_id, null, v_points,
'Course du Char — ' || to_char(v_target_date, 'DD/MM') || ' (rang ' || r.rnk || ')');
end loop;
insert into public.chariot_race_closes (race_date) values (v_target_date);
end;
$$;
-- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase
-- (Database → Extensions → "pg_cron" → Enable) — pas scriptable depuis ce
-- fichier, et pas garanti self-service selon le plan/la région du projet.
-- Les deux blocs ci-dessous n'échouent jamais bruyamment si l'extension n'est
-- pas encore activée (schéma "cron" inexistant) : le reste de ce script,
-- ré-exécuté en entier à chaque changement, doit toujours pouvoir passer.
do $$
begin
perform cron.unschedule('close-daily-chariot-race');
exception
when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé
end $$;
do $$
begin
perform cron.schedule(
'close-daily-chariot-race',
'*/15 * * * *',
$cron$select public.close_daily_chariot_race();$cron$
);
exception
when others then
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier la clôture automatique de la Course du Char.';
end $$;