Ajoute La Course du Char : mini-jeu de course 2D quotidien
Build and deploy / deploy (push) Successful in 37s
Build and deploy / deploy (push) Successful in 37s
Premier mini-jeu compétitif du Tribunal (/course) : piste 2D générée de façon déterministe à partir de la date du jour (identique pour tout le monde, jamais stockée côté serveur), pilotage au joystick tactile virtuel, murs + obstacles qui ralentissent temporairement sans jamais stopper la course, fantômes du top 3 du jour. Chacun rejoue autant qu'il veut, seul le meilleur temps compte. Moteur de jeu maison (src/lib/chariot/) : canvas + requestAnimationFrame, sans dépendance externe, dans la continuité de l'existant (roulette). Navigation : nouvelle entrée "La Course du Char" (IconChariot), route protégée par le proxy comme le reste de l'app.
This commit is contained in:
@@ -6,14 +6,15 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
|
||||
|
||||
## 1. État actuel
|
||||
|
||||
V1 à V5 sont livrées :
|
||||
V1 à V6 sont livrées :
|
||||
- **V1** : comptes, profils, leaderboard statique.
|
||||
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
|
||||
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
|
||||
- **V4** : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur.
|
||||
- **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page **La Roulette** (tirage au sort animé), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement.
|
||||
- **V6** : premier mini-jeu compétitif, **La Course du Char** (`/course`) — piste 2D générée aléatoirement chaque jour (identique pour tout le monde, calculée côté client à partir de la date), joystick tactile virtuel, murs/obstacles qui ralentissent temporairement, fantômes du top 3 du jour, seul mécanisme de points de tout le projet qui s'attribue **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte).
|
||||
|
||||
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le jeu du Tribunal lui-même (Char, ostracisme, timer du Gardien), mode grand écran.
|
||||
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le reste du jeu du Tribunal (ostracisme, timer du Gardien — la Course du Char est livrée en V6), mode grand écran.
|
||||
|
||||
---
|
||||
|
||||
@@ -56,6 +57,12 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
|
||||
- `settings` : ligne unique (`id boolean primary key default true`, contrainte `check (id)`) — `tribunal_date`.
|
||||
- RLS : `SELECT` ouvert à tout authentifié ; `INSERT`/`UPDATE`/`DELETE` réservés au rôle `judge`, vérifié par policy (`exists (select 1 from profiles where id = auth.uid() and role = 'judge')`) — pas de trigger `BEFORE UPDATE` ici car il n'y a pas de colonne à protéger *dans une ligne par ailleurs modifiable par tous* (contrairement à `profiles`) : toute la table est verrouillée en écriture aux juges.
|
||||
|
||||
### `public.chariot_runs` / `public.chariot_race_closes` (La Course du Char)
|
||||
|
||||
- `chariot_runs` : `user_id, race_date, best_time_ms, ghost_path (jsonb), updated_at` — clé primaire `(user_id, race_date)`, une seule ligne par joueur et par jour (le meilleur temps uniquement, pas l'historique des tentatives). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_chariot_run`.
|
||||
- `chariot_race_closes` : table interne (aucune policy) qui marque les jours déjà clôturés, pour que la tâche planifiée reste idempotente.
|
||||
- Pas de table `tracks` : la piste du jour est une fonction pure côté client (`src/lib/chariot/track.ts`, `generateTrack(dateKey)`), graine = date du jour Europe/Paris — jamais stockée côté serveur.
|
||||
|
||||
### Colonnes sensibles : RLS + trigger, jamais confiance au client
|
||||
|
||||
La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` :
|
||||
@@ -71,6 +78,8 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
|
||||
- `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`).
|
||||
- `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client.
|
||||
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
|
||||
- `submit_chariot_run(p_time_ms, p_ghost_path)` — authenticated, calcule `race_date` côté serveur (jamais fourni par le client), borne le temps (anti-triche minimal), n'écrase le meilleur temps du jour que s'il est strictement battu.
|
||||
- `close_daily_chariot_race()` — **pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : classe la journée précédente, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur).
|
||||
|
||||
---
|
||||
|
||||
@@ -89,12 +98,13 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
|
||||
| `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email |
|
||||
| `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges |
|
||||
| `/roulette` | « La Roulette » — tirage au sort animé parmi les membres (ou Citoyens uniquement) |
|
||||
| `/course` | « La Course du Char » — mini-jeu de course 2D (piste quotidienne, joystick tactile, fantômes du top 3), points attribués automatiquement |
|
||||
| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges |
|
||||
| `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
|
||||
| `/profile` | Pseudo (verrouillable), photo, déconnexion |
|
||||
| `/admin` | « Le Conseil des Archontes » — juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement |
|
||||
|
||||
`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front).
|
||||
`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier`, `/course` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front).
|
||||
|
||||
Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée.
|
||||
|
||||
@@ -143,6 +153,6 @@ Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code
|
||||
## 8. Roadmap (pas encore codé)
|
||||
|
||||
- **Éditions** : table `editions` (année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition.
|
||||
- **Le jeu du Tribunal** : le Char (zone de pénalité), batailles de cul sec, ostracisme, timer du Gardien. (La Roulette est livrée en V5.)
|
||||
- **Le jeu du Tribunal (suite)** : batailles de cul sec, ostracisme, timer du Gardien. (La Roulette est livrée en V5, la Course du Char en V6.)
|
||||
- **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur.
|
||||
- **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconLogout,
|
||||
IconChevronDown,
|
||||
IconCalendar,
|
||||
IconChariot,
|
||||
} from "@/components/icons";
|
||||
|
||||
type MenuEntry = {
|
||||
@@ -91,6 +92,7 @@ export function Header({
|
||||
const entries: MenuEntry[] = [
|
||||
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
|
||||
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> },
|
||||
{ href: "/course", label: "La Course du Char", icon: <IconChariot /> },
|
||||
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
|
||||
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
|
||||
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> },
|
||||
|
||||
@@ -241,3 +241,15 @@ export function IconBolt({ className = base }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChariot({ className = base }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
|
||||
<circle cx="6.5" cy="18" r="2.3" />
|
||||
<circle cx="17.5" cy="18" r="2.3" />
|
||||
<path d="M6.5 15.7V10a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v5.7" strokeLinejoin="round" />
|
||||
<path d="M8.5 8 11 3h3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M10.5 12h5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,7 +1,15 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette", "/calendrier"];
|
||||
const PROTECTED_PATHS = [
|
||||
"/leaderboard",
|
||||
"/profile",
|
||||
"/admin",
|
||||
"/journal",
|
||||
"/roulette",
|
||||
"/calendrier",
|
||||
"/course",
|
||||
];
|
||||
// /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une
|
||||
// session (établie par le lien d'invitation) mais doit pouvoir rester sur
|
||||
// /signup pour finaliser mot de passe/pseudo/photo sans être renvoyé au
|
||||
|
||||
Reference in New Issue
Block a user