Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6af56d2ade | |||
| 9f54b459f3 | |||
| 777d9ebaea | |||
| f462cff8a7 |
@@ -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).
|
||||||
|
|||||||
@@ -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,15 +31,12 @@ 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>
|
</div>
|
||||||
<ResetRoundButton />
|
|
||||||
</div>
|
|
||||||
<AdminMembersTable members={members ?? []} currentUserId={user.id} />
|
<AdminMembersTable members={members ?? []} currentUserId={user.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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'hui :{" "}
|
||||||
|
<span className="text-gold-bright">{formatRaceTime(ownBestMs)}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* key=track.seed : force un remontage complet (état de jeu neuf) si la
|
||||||
|
piste change (nouveau jour), plutôt qu'un effet qui réinitialiserait
|
||||||
|
l'état depuis l'intérieur du composant. */}
|
||||||
|
<ChariotGame key={track.seed} track={track} ghosts={ghosts} onFinish={handleFinish} />
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-text-mut">
|
||||||
|
Le podium du jour reçoit des gloires automatiquement à minuit — aucun Archonte n'a besoin
|
||||||
|
d'intervenir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'emporte.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CourseView today={today} initialTopRuns={topRuns} initialOwnBestMs={ownRun?.best_time_ms ?? null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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"],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
|
|||||||
Reference in New Issue
Block a user