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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user