Remplace la Course du Char par Le Vol d'Icare
Build and deploy / deploy (push) Successful in 37s

Après retour d'expérience, la course de char (vue du dessus, piste
générée par jour) laisse place à un Flappy Bird grec plus simple et
plus lisible sur téléphone : Icare vole entre des colonnes de temple,
touche l'écran pour battre des ailes, échec net au premier contact
(score remis à zéro).

Record personnel all-time (plus de piste quotidienne ni de fantômes) :
les scores se figent dès que la date du Tribunal est atteinte, puis les
gloires du top 3 sont attribuées automatiquement via une tâche
planifiée pg_cron, comme pour l'ancienne Course du Char.

schema.sql nettoie explicitement l'ancienne Course du Char (tables,
RPC, tâche planifiée) avant de poser le nouveau schéma, puisqu'elle
avait déjà été appliquée en prod.
This commit is contained in:
Valentin ROBIN
2026-07-29 19:14:52 +02:00
parent 6af56d2ade
commit 33d2476ef6
22 changed files with 636 additions and 1054 deletions
-323
View File
@@ -1,323 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { FIXED_DT_MS, JOYSTICK_MAX_RADIUS, MAX_FRAME_DT_MS } from "@/lib/chariot/constants";
import { formatRaceTime } from "@/lib/chariot/format";
import { finishGhost, recordGhostSample, sampleGhostAt } from "@/lib/chariot/ghost";
import { createInitialState, crossedFinish, step } from "@/lib/chariot/physics";
import type { ChariotState, GhostSample, Track } from "@/lib/chariot/types";
type Status = "idle" | "racing" | "finished";
const GHOST_COLORS = ["#E7C560", "#C7CDD6", "#B08D57"];
function drawChariot(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
heading: number,
color: string,
alpha: number,
) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(x, y);
ctx.rotate(heading);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(15, 0);
ctx.lineTo(-9, 8);
ctx.lineTo(-9, -8);
ctx.closePath();
ctx.fill();
ctx.restore();
}
export function ChariotGame({
track,
ghosts,
onFinish,
}: {
track: Track;
ghosts: GhostSample[][];
onFinish: (timeMs: number, ghostPath: GhostSample[]) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [finishedMs, setFinishedMs] = useState<number | null>(null);
const ghostsRef = useRef(ghosts);
const onFinishRef = useRef(onFinish);
useEffect(() => {
ghostsRef.current = ghosts;
}, [ghosts]);
useEffect(() => {
onFinishRef.current = onFinish;
}, [onFinish]);
// État de jeu tenu en ref : évite un re-render React à 60 fps.
const chariotRef = useRef<ChariotState>(createInitialState(track));
const inputRef = useRef({ x: 0, y: 0 });
const pointerOriginRef = useRef<{ x: number; y: number; id: number } | null>(null);
const statusRef = useRef<Status>("idle");
const startedAtRef = useRef<number | null>(null);
const ghostPathRef = useRef<GhostSample[]>([]);
const scaleRef = useRef(1);
function resetRace() {
chariotRef.current = createInitialState(track);
statusRef.current = "idle";
startedAtRef.current = null;
ghostPathRef.current = [];
setStatus("idle");
setFinishedMs(null);
}
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
function resize() {
const dpr = window.devicePixelRatio || 1;
const cssWidth = container!.clientWidth;
const cssHeight = cssWidth * (track.world.height / track.world.width);
canvas!.width = cssWidth * dpr;
canvas!.height = cssHeight * dpr;
canvas!.style.width = `${cssWidth}px`;
canvas!.style.height = `${cssHeight}px`;
scaleRef.current = cssWidth / track.world.width;
}
resize();
const observer = new ResizeObserver(resize);
observer.observe(container);
function handlePointerDown(event: PointerEvent) {
canvas!.setPointerCapture(event.pointerId);
const rect = canvas!.getBoundingClientRect();
pointerOriginRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
id: event.pointerId,
};
if (statusRef.current === "idle") {
statusRef.current = "racing";
setStatus("racing");
startedAtRef.current = performance.now();
}
}
function handlePointerMove(event: PointerEvent) {
const origin = pointerOriginRef.current;
if (!origin || origin.id !== event.pointerId) return;
const rect = canvas!.getBoundingClientRect();
const dx = event.clientX - rect.left - origin.x;
const dy = event.clientY - rect.top - origin.y;
const dist = Math.hypot(dx, dy);
const clamped = Math.min(1, dist / JOYSTICK_MAX_RADIUS);
inputRef.current = dist > 0.001 ? { x: (dx / dist) * clamped, y: (dy / dist) * clamped } : { x: 0, y: 0 };
}
function handlePointerUp(event: PointerEvent) {
if (pointerOriginRef.current?.id === event.pointerId) {
pointerOriginRef.current = null;
inputRef.current = { x: 0, y: 0 };
}
}
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerUp);
canvas.addEventListener("pointercancel", handlePointerUp);
let rafId: number;
let lastFrame: number | null = null;
let accumulator = 0;
function frame(now: number) {
if (lastFrame === null) lastFrame = now;
const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS);
lastFrame = now;
if (statusRef.current === "racing" && startedAtRef.current !== null) {
accumulator += frameDt;
const elapsedMs = now - startedAtRef.current;
while (accumulator >= FIXED_DT_MS) {
const prevPosition = { ...chariotRef.current.position };
chariotRef.current = step(chariotRef.current, inputRef.current, track, FIXED_DT_MS, elapsedMs);
accumulator -= FIXED_DT_MS;
if (crossedFinish(prevPosition, chariotRef.current.position, track)) {
statusRef.current = "finished";
finishGhost(
ghostPathRef.current,
elapsedMs,
chariotRef.current.position.x,
chariotRef.current.position.y,
chariotRef.current.heading,
);
setStatus("finished");
setFinishedMs(elapsedMs);
onFinishRef.current(elapsedMs, ghostPathRef.current);
break;
}
}
if (statusRef.current === "racing") {
recordGhostSample(
ghostPathRef.current,
elapsedMs,
chariotRef.current.position.x,
chariotRef.current.position.y,
chariotRef.current.heading,
);
}
}
render(ctx!, canvas!, track, chariotRef.current, ghostsRef.current, startedAtRef.current, statusRef.current, pointerOriginRef.current, inputRef.current);
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
return () => {
observer.disconnect();
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerup", handlePointerUp);
canvas.removeEventListener("pointercancel", handlePointerUp);
cancelAnimationFrame(rafId);
};
}, [track]);
return (
<div className="flex flex-col items-center gap-3">
<div
ref={containerRef}
className="relative w-full max-w-sm select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
>
<canvas ref={canvasRef} className="block h-full w-full touch-none" />
</div>
{status === "idle" && (
<p className="text-center text-sm text-text-mut">
Pose le doigt sur la piste et glisse pour diriger le char.
</p>
)}
{status === "finished" && finishedMs !== null && (
<div className="marble-surface flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-4 py-3 text-center shadow-lg">
<p className="font-heading text-lg tracking-wide text-gold-bright">{formatRaceTime(finishedMs)}</p>
<button
type="button"
onClick={resetRace}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink"
>
Rejouer
</button>
</div>
)}
</div>
);
}
function render(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
track: Track,
chariot: ChariotState,
ghosts: GhostSample[][],
startedAt: number | null,
status: Status,
pointerOrigin: { x: number; y: number; id: number } | null,
input: { x: number; y: number },
) {
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const worldScale = cssWidth / track.world.width;
const elapsedMs = startedAt !== null ? performance.now() - startedAt : 0;
ctx.save();
ctx.scale(dpr, dpr);
// -- Espace "monde" : piste, obstacles, fantômes, char.
ctx.save();
ctx.scale(worldScale, worldScale);
ctx.clearRect(0, 0, track.world.width, track.world.height);
ctx.fillStyle = "#0A1B33";
ctx.fillRect(0, 0, track.world.width, track.world.height);
ctx.lineWidth = track.halfWidth * 2;
ctx.strokeStyle = "#F4ECD8";
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
track.centerline.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y);
else ctx.lineTo(point.x, point.y);
});
ctx.stroke();
ctx.strokeStyle = "#C9A227";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(track.finish.a.x, track.finish.a.y);
ctx.lineTo(track.finish.b.x, track.finish.b.y);
ctx.stroke();
ctx.fillStyle = "#A5342A";
for (const obstacle of track.obstacles) {
ctx.beginPath();
ctx.arc(obstacle.position.x, obstacle.position.y, obstacle.radius, 0, Math.PI * 2);
ctx.fill();
}
if (startedAt !== null) {
ghosts.forEach((path, index) => {
const sample = sampleGhostAt(path, elapsedMs);
if (!sample) return;
drawChariot(ctx, sample.x, sample.y, sample.a, GHOST_COLORS[index % GHOST_COLORS.length], 0.45);
});
}
drawChariot(ctx, chariot.position.x, chariot.position.y, chariot.heading, "#E7C560", 1);
ctx.restore();
// -- Espace "écran" (pixels CSS) : joystick tactile et chrono.
if (pointerOrigin) {
const nubX = pointerOrigin.x + input.x * JOYSTICK_MAX_RADIUS;
const nubY = pointerOrigin.y + input.y * JOYSTICK_MAX_RADIUS;
ctx.save();
ctx.globalAlpha = 0.35;
ctx.strokeStyle = "#E7C560";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(pointerOrigin.x, pointerOrigin.y, JOYSTICK_MAX_RADIUS, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 0.8;
ctx.fillStyle = "#E7C560";
ctx.beginPath();
ctx.arc(nubX, nubY, 16, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
if (status === "racing" && startedAt !== null) {
ctx.save();
ctx.fillStyle = "#F4ECD8";
ctx.font = "600 20px Cinzel, serif";
ctx.textAlign = "center";
ctx.fillText(formatRaceTime(elapsedMs), cssWidth / 2, 30);
ctx.restore();
}
ctx.restore();
}
-118
View File
@@ -1,118 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Podium } from "@/components/podium";
import { formatRaceTime } from "@/lib/chariot/format";
import { generateTrack } from "@/lib/chariot/track";
import type { GhostSample } from "@/lib/chariot/types";
import { computeRanksBy } from "@/lib/ranking";
import { createClient } from "@/lib/supabase/client";
import { ChariotGame } from "./chariot-game";
import type { ChariotLeaderboardEntry } from "./page";
export function CourseView({
today,
initialTopRuns,
initialOwnBestMs,
}: {
today: string;
initialTopRuns: ChariotLeaderboardEntry[];
initialOwnBestMs: number | null;
}) {
const [topRuns, setTopRuns] = useState(initialTopRuns);
const [ownBestMs, setOwnBestMs] = useState(initialOwnBestMs);
const track = useMemo(() => generateTrack(today), [today]);
const refetch = useCallback(async () => {
const supabase = createClient();
const { data: runs } = await supabase
.from("chariot_runs")
.select("user_id, best_time_ms, ghost_path")
.eq("race_date", today)
.order("best_time_ms", { ascending: true })
.limit(3);
const userIds = (runs ?? []).map((run) => run.user_id);
const { data: runProfiles } =
userIds.length > 0
? await supabase.from("profiles").select("id, pseudo, avatar_url").in("id", userIds)
: { data: [] as { id: string; pseudo: string; avatar_url: string | null }[] };
const profileById = new Map((runProfiles ?? []).map((profile) => [profile.id, profile]));
setTopRuns(
(runs ?? []).map((run) => ({
id: run.user_id,
pseudo: profileById.get(run.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: profileById.get(run.user_id)?.avatar_url ?? null,
best_time_ms: run.best_time_ms,
ghost_path: run.ghost_path,
})),
);
}, [today]);
// Comme Le Calendrier des Dieux : on ré-interroge tout à chaque changement
// plutôt que de fusionner le payload localement — le client ne voit que le
// top 3 du jour, pas tout le classement, donc une fusion partielle ne
// suffirait pas à recalculer correctement qui en fait partie.
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("chariot-runs")
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_runs" }, () => {
refetch();
})
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [refetch]);
async function handleFinish(timeMs: number, ghostPath: GhostSample[]) {
const supabase = createClient();
const { data, error } = await supabase.rpc("submit_chariot_run", {
p_time_ms: Math.round(timeMs),
p_ghost_path: ghostPath,
});
if (!error && data) {
setOwnBestMs(data.best_time_ms);
refetch();
}
}
const ranked = useMemo(
() => computeRanksBy(topRuns, (a, b) => a.best_time_ms - b.best_time_ms),
[topRuns],
);
const ghosts = useMemo(
() => topRuns.map((run) => (Array.isArray(run.ghost_path) ? (run.ghost_path as GhostSample[]) : [])),
[topRuns],
);
return (
<div className="flex flex-col gap-6">
{ranked.length > 0 && (
<Podium profiles={ranked} renderValue={(member) => formatRaceTime(member.best_time_ms)} />
)}
{ownBestMs !== null && (
<p className="text-center font-heading text-sm tracking-wide text-text-marble">
Ton meilleur temps aujourd&apos;hui :{" "}
<span className="text-gold-bright">{formatRaceTime(ownBestMs)}</span>
</p>
)}
{/* key=track.seed : force un remontage complet (état de jeu neuf) si la
piste change (nouveau jour), plutôt qu'un effet qui réinitialiserait
l'état depuis l'intérieur du composant. */}
<ChariotGame key={track.seed} track={track} ghosts={ghosts} onFinish={handleFinish} />
<p className="text-center text-xs text-text-mut">
Le podium du jour reçoit des gloires automatiquement à minuit aucun Archonte n&apos;a besoin
d&apos;intervenir.
</p>
</div>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { CourseView } from "./course-view";
export type ChariotLeaderboardEntry = {
id: string;
pseudo: string;
avatar_url: string | null;
best_time_ms: number;
ghost_path: unknown;
};
export default async function CoursePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
// Même formule que Le Calendrier des Dieux : la date du jour en
// Europe/Paris, seule source de vérité pour "quelle piste" et "quel jour".
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
const { data: runs } = await supabase
.from("chariot_runs")
.select("user_id, best_time_ms, ghost_path")
.eq("race_date", today)
.order("best_time_ms", { ascending: true })
.limit(3);
const userIds = (runs ?? []).map((run) => run.user_id);
const { data: runProfiles } =
userIds.length > 0
? await supabase.from("profiles").select("id, pseudo, avatar_url").in("id", userIds)
: { data: [] as { id: string; pseudo: string; avatar_url: string | null }[] };
const profileById = new Map((runProfiles ?? []).map((profile) => [profile.id, profile]));
const topRuns: ChariotLeaderboardEntry[] = (runs ?? []).map((run) => ({
id: run.user_id,
pseudo: profileById.get(run.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: profileById.get(run.user_id)?.avatar_url ?? null,
best_time_ms: run.best_time_ms,
ghost_path: run.ghost_path,
}));
const { data: ownRun } = await supabase
.from("chariot_runs")
.select("best_time_ms")
.eq("race_date", today)
.eq("user_id", user.id)
.maybeSingle();
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
La Course du Char
</h1>
<p className="font-serif text-sm text-marble/60 italic">
Une piste par jour, le meilleur temps l&apos;emporte.
</p>
</div>
<CourseView today={today} initialTopRuns={topRuns} initialOwnBestMs={ownRun?.best_time_ms ?? null} />
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Avatar } from "@/components/avatar";
import { computeRanksBy } from "@/lib/ranking";
import { createClient } from "@/lib/supabase/client";
import { IcarusGame } from "./icarus-game";
import type { IcarusScoreEntry } from "./page";
export function IcareView({
initialLeaderboard,
initialOwnBestScore,
isFrozen,
}: {
initialLeaderboard: IcarusScoreEntry[];
initialOwnBestScore: number | null;
isFrozen: boolean;
}) {
const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
const refetch = useCallback(async () => {
const supabase = createClient();
const { data: scores } = await supabase
.from("icarus_scores")
.select("user_id, best_score")
.order("best_score", { ascending: false });
const userIds = (scores ?? []).map((row) => row.user_id);
const { data: profiles } =
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((profiles ?? []).map((profile) => [profile.id, profile]));
setLeaderboard(
(scores ?? []).map((row) => ({
id: row.user_id,
pseudo: profileById.get(row.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: profileById.get(row.user_id)?.avatar_url ?? null,
best_score: row.best_score,
})),
);
}, []);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("icarus-scores")
.on("postgres_changes", { event: "*", schema: "public", table: "icarus_scores" }, () => {
refetch();
})
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [refetch]);
async function handleFinish(score: number) {
const supabase = createClient();
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score });
if (!error && data) {
setOwnBestScore(data.best_score);
refetch();
}
}
const ranked = useMemo(() => computeRanksBy(leaderboard, (a, b) => b.best_score - a.best_score), [leaderboard]);
return (
<div className="flex flex-col gap-6">
{isFrozen && (
<p className="marble-surface rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble">
Les scores sont figés depuis le début du Tribunal.
</p>
)}
{ranked.length > 0 && (
<ol className="flex flex-col gap-1.5">
{ranked.map((entry) => (
<li
key={entry.id}
className="marble-surface flex items-center gap-3 rounded-lg border border-gold/25 px-3 py-2 shadow-sm"
>
<span className="w-5 shrink-0 text-center font-heading text-sm text-text-mut">{entry.rank}</span>
<Avatar pseudo={entry.pseudo} avatarUrl={entry.avatar_url} size="xs" />
<span className="flex-1 truncate text-sm font-medium text-text-marble">{entry.pseudo}</span>
<span className="font-heading text-sm font-semibold text-gold-bright">{entry.best_score}</span>
</li>
))}
</ol>
)}
{ownBestScore !== null && (
<p className="text-center font-heading text-sm tracking-wide text-text-marble">
Ton record : <span className="text-gold-bright">{ownBestScore}</span>
</p>
)}
<IcarusGame onFinish={handleFinish} />
<p className="text-center text-xs text-text-mut">
{isFrozen
? "Les gloires du podium ont été attribuées automatiquement au début du Tribunal."
: "Au début du Tribunal, les scores seront figés et le podium recevra des gloires automatiquement — aucun Archonte n'a besoin d'intervenir."}
</p>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
COLUMN_GAP,
COLUMN_GAP_MARGIN,
COLUMN_SPACING,
COLUMN_WIDTH,
FIXED_DT_MS,
FLAP_IMPULSE,
FORWARD_SPEED,
GROUND_Y,
ICARUS_RADIUS,
ICARUS_X,
MAX_FRAME_DT_MS,
TILT_MAX_RADIANS,
VIEWPORT_HEIGHT,
VIEWPORT_WIDTH,
} from "@/lib/icarus/constants";
import { collidesWithColumn, createInitialIcarus, hasHitGround, stepIcarus } from "@/lib/icarus/physics";
import type { Column, IcarusState } from "@/lib/icarus/types";
type Status = "idle" | "flying" | "dead";
function randomGapCenter(): number {
const minCenter = COLUMN_GAP / 2 + COLUMN_GAP_MARGIN;
const maxCenter = GROUND_Y - COLUMN_GAP / 2 - COLUMN_GAP_MARGIN;
return minCenter + Math.random() * Math.max(0, maxCenter - minCenter);
}
function drawIcarus(ctx: CanvasRenderingContext2D, y: number, tilt: number) {
ctx.save();
ctx.translate(ICARUS_X, y);
ctx.rotate(tilt);
ctx.fillStyle = "#F4ECD8";
ctx.beginPath();
ctx.moveTo(-2, -2);
ctx.lineTo(-ICARUS_RADIUS * 2.2, -ICARUS_RADIUS * 1.3);
ctx.lineTo(-ICARUS_RADIUS * 0.5, ICARUS_RADIUS * 0.5);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(2, -2);
ctx.lineTo(ICARUS_RADIUS * 2.2, -ICARUS_RADIUS * 1.3);
ctx.lineTo(ICARUS_RADIUS * 0.5, ICARUS_RADIUS * 0.5);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#E7C560";
ctx.beginPath();
ctx.arc(0, 0, ICARUS_RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawColumnShaft(
ctx: CanvasRenderingContext2D,
x: number,
top: number,
height: number,
capAtBottom: boolean,
) {
if (height <= 0) return;
ctx.fillStyle = "#F4ECD8";
ctx.fillRect(x, top, COLUMN_WIDTH, height);
ctx.strokeStyle = "#C9A227";
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
const lineX = x + (COLUMN_WIDTH / 4) * i;
ctx.beginPath();
ctx.moveTo(lineX, top);
ctx.lineTo(lineX, top + height);
ctx.stroke();
}
const capHeight = Math.min(10, height);
const capY = capAtBottom ? top + height - capHeight : top;
ctx.fillStyle = "#E7C560";
ctx.fillRect(x - 4, capY, COLUMN_WIDTH + 8, capHeight);
}
function drawColumn(ctx: CanvasRenderingContext2D, column: Column) {
const gapTop = column.gapCenterY - COLUMN_GAP / 2;
const gapBottom = column.gapCenterY + COLUMN_GAP / 2;
drawColumnShaft(ctx, column.x, 0, gapTop, true);
drawColumnShaft(ctx, column.x, gapBottom, GROUND_Y - gapBottom, false);
}
export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [score, setScore] = useState(0);
const onFinishRef = useRef(onFinish);
useEffect(() => {
onFinishRef.current = onFinish;
}, [onFinish]);
const icarusRef = useRef<IcarusState>(createInitialIcarus());
const columnsRef = useRef<Column[]>([]);
const statusRef = useRef<Status>("idle");
const scoreRef = useRef(0);
const flapRequestedRef = useRef(false);
function resetGame() {
icarusRef.current = createInitialIcarus();
columnsRef.current = [];
scoreRef.current = 0;
statusRef.current = "idle";
setStatus("idle");
setScore(0);
}
function requestFlap() {
flapRequestedRef.current = true;
if (statusRef.current === "idle") {
statusRef.current = "flying";
setStatus("flying");
} else if (statusRef.current === "dead") {
resetGame();
}
}
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 * (VIEWPORT_HEIGHT / VIEWPORT_WIDTH);
canvas!.width = cssWidth * dpr;
canvas!.height = cssHeight * dpr;
canvas!.style.width = `${cssWidth}px`;
canvas!.style.height = `${cssHeight}px`;
}
resize();
const observer = new ResizeObserver(resize);
observer.observe(container);
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 === "flying") {
accumulator += frameDt;
while (accumulator >= FIXED_DT_MS) {
if (flapRequestedRef.current) {
icarusRef.current = { ...icarusRef.current, velocityY: FLAP_IMPULSE };
}
icarusRef.current = stepIcarus(icarusRef.current, FIXED_DT_MS);
for (const column of columnsRef.current) {
column.x -= FORWARD_SPEED * (FIXED_DT_MS / 1000);
if (!column.scored && column.x + COLUMN_WIDTH < ICARUS_X - ICARUS_RADIUS) {
column.scored = true;
scoreRef.current += 1;
setScore(scoreRef.current);
}
}
columnsRef.current = columnsRef.current.filter((c) => c.x + COLUMN_WIDTH > 0);
const last = columnsRef.current[columnsRef.current.length - 1];
if (!last || last.x < VIEWPORT_WIDTH - COLUMN_SPACING) {
columnsRef.current.push({
x: last ? last.x + COLUMN_SPACING : VIEWPORT_WIDTH,
gapCenterY: randomGapCenter(),
scored: false,
});
}
const hitColumn = columnsRef.current.some((c) => collidesWithColumn(icarusRef.current.y, c));
if (hasHitGround(icarusRef.current) || hitColumn) {
statusRef.current = "dead";
setStatus("dead");
onFinishRef.current(scoreRef.current);
break;
}
accumulator -= FIXED_DT_MS;
}
}
flapRequestedRef.current = false;
render(ctx!, canvas!, icarusRef.current, columnsRef.current, statusRef.current, scoreRef.current);
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
return () => {
observer.disconnect();
cancelAnimationFrame(rafId);
};
}, []);
return (
<div className="flex flex-col items-center gap-3">
<div
ref={containerRef}
onPointerDown={requestFlap}
className="relative w-full max-w-xs touch-none select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
<p className="text-center font-heading text-2xl text-gold-bright">{score}</p>
{status === "idle" && (
<p className="text-center text-sm text-text-mut">
Touche l&apos;écran pour battre des ailes et t&apos;envoler entre les colonnes.
</p>
)}
{status === "dead" && (
<button
type="button"
onClick={resetGame}
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>
);
}
function render(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
icarus: IcarusState,
columns: Column[],
status: Status,
score: number,
) {
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const worldScale = cssWidth / VIEWPORT_WIDTH;
ctx.save();
ctx.scale(dpr, dpr);
ctx.save();
ctx.scale(worldScale, worldScale);
ctx.clearRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
ctx.fillStyle = "#0A1B33";
ctx.fillRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
for (const column of columns) {
drawColumn(ctx, column);
}
ctx.fillStyle = "#2A2116";
ctx.fillRect(0, GROUND_Y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT - GROUND_Y);
const tilt = Math.max(-1, Math.min(1, icarus.velocityY / 300)) * TILT_MAX_RADIANS;
drawIcarus(ctx, icarus.y, tilt);
ctx.restore();
if (status === "flying") {
ctx.save();
ctx.fillStyle = "#F4ECD8";
ctx.font = "700 28px Cinzel, serif";
ctx.textAlign = "center";
ctx.fillText(String(score), cssWidth / 2, 44);
ctx.restore();
}
ctx.restore();
}
+61
View File
@@ -0,0 +1,61 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { IcareView } from "./icare-view";
export type IcarusScoreEntry = {
id: string;
pseudo: string;
avatar_url: string | null;
best_score: number;
};
export default async function IcarePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: scores } = await supabase
.from("icarus_scores")
.select("user_id, best_score")
.order("best_score", { ascending: false });
const userIds = (scores ?? []).map((row) => row.user_id);
const { data: scoreProfiles } =
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((scoreProfiles ?? []).map((profile) => [profile.id, profile]));
const leaderboard: IcarusScoreEntry[] = (scores ?? []).map((row) => ({
id: row.user_id,
pseudo: profileById.get(row.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: profileById.get(row.user_id)?.avatar_url ?? null,
best_score: row.best_score,
}));
const { data: settings } = await supabase
.from("settings")
.select("tribunal_date")
.eq("id", true)
.single();
const isFrozen = Boolean(settings?.tribunal_date && new Date() >= new Date(settings.tribunal_date));
const ownBestScore = leaderboard.find((entry) => entry.id === user.id)?.best_score ?? null;
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">Le Vol d&apos;Icare</h1>
<p className="font-serif text-sm text-marble/60 italic">
Vole entre les colonnes, aussi loin que tes ailes de cire le permettent.
</p>
</div>
<IcareView initialLeaderboard={leaderboard} initialOwnBestScore={ownBestScore} isFrozen={isFrozen} />
</div>
);
}
+2 -2
View File
@@ -15,7 +15,7 @@ import {
IconLogout,
IconChevronDown,
IconCalendar,
IconChariot,
IconWings,
} from "@/components/icons";
type MenuEntry = {
@@ -92,7 +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: "/icare", label: "Le Vol d'Icare", icon: <IconWings /> },
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> },
+12 -6
View File
@@ -242,14 +242,20 @@ export function IconBolt({ className = base }: IconProps) {
);
}
export function IconChariot({ className = base }: IconProps) {
export function IconWings({ 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" />
<path d="M12 5v14" strokeLinecap="round" />
<path
d="M12 8c-2-3-6-4-9-3 1 3 1 6 4 8 2 1.4 4 1.2 5 .5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 8c2-3 6-4 9-3-1 3-1 6-4 8-2 1.4-4 1.2-5 .5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
-34
View File
@@ -1,34 +0,0 @@
// Espace monde fixe (pas de caméra qui suit le char : toute la piste tient
// dans cet écran, mis à l'échelle au rendu selon la taille du canvas).
export const WORLD_WIDTH = 400;
export const WORLD_HEIGHT = 720;
export const CORRIDOR_HALF_WIDTH = 55;
export const CHARIOT_RADIUS = 14;
export const OBSTACLE_RADIUS = 16;
export const WAYPOINT_COUNT = 12;
export const WAYPOINT_STEP = 1; // unité arbitraire, la piste est remise à l'échelle après génération
export const MAX_TURN_RADIANS = (34 * Math.PI) / 180;
export const SPLINE_SAMPLES_PER_SEGMENT = 10;
export const OBSTACLE_COUNT = 8;
export const OBSTACLE_EDGE_BUFFER_RATIO = 0.08; // pas d'obstacle dans les 8% de début/fin
export const OBSTACLE_LATERAL_MARGIN = 12; // marge par rapport aux murs
export const MAX_SPEED = 230; // unités/seconde
export const MAX_ACCEL = 900; // unités/seconde², vitesse réelle -> vitesse cible
export const FRICTION_WHEN_IDLE = 500; // décélération quand le joystick est relâché
export const SLOWDOWN_SPEED_FACTOR = 0.4; // vitesse max plafonnée juste après un choc
export const SLOWDOWN_RECOVERY_MS = 900;
export const JOYSTICK_MAX_RADIUS = 70; // px, rayon de clamp du glissement tactile
export const FIXED_DT_MS = 1000 / 60;
export const MAX_FRAME_DT_MS = 250; // évite la "spirale de la mort" après un onglet en arrière-plan
export const GHOST_SAMPLE_INTERVAL_MS = 80;
export const MIN_TIME_MS = 2000;
export const MAX_TIME_MS = 300000;
-7
View File
@@ -1,7 +0,0 @@
export function formatRaceTime(ms: number): string {
const totalCentiseconds = Math.round(ms / 10);
const minutes = Math.floor(totalCentiseconds / 6000);
const seconds = Math.floor((totalCentiseconds % 6000) / 100);
const centiseconds = totalCentiseconds % 100;
return `${minutes}:${seconds.toString().padStart(2, "0")}.${centiseconds.toString().padStart(2, "0")}`;
}
-77
View File
@@ -1,77 +0,0 @@
export type Vec2 = { x: number; y: number };
export function add(a: Vec2, b: Vec2): Vec2 {
return { x: a.x + b.x, y: a.y + b.y };
}
export function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
export function scale(a: Vec2, s: number): Vec2 {
return { x: a.x * s, y: a.y * s };
}
export function length(a: Vec2): number {
return Math.hypot(a.x, a.y);
}
export function normalize(a: Vec2): Vec2 {
const len = length(a);
return len > 0 ? scale(a, 1 / len) : { x: 0, y: 0 };
}
export function dot(a: Vec2, b: Vec2): number {
return a.x * b.x + a.y * b.y;
}
export function rotate90(a: Vec2): Vec2 {
return { x: -a.y, y: a.x };
}
// Point le plus proche du point p sur le segment [a,b].
export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): { point: Vec2; t: number } {
const ab = sub(b, a);
const abLenSq = dot(ab, ab);
const t = abLenSq > 0 ? Math.max(0, Math.min(1, dot(sub(p, a), ab) / abLenSq)) : 0;
return { point: add(a, scale(ab, t)), t };
}
// Catmull-Rom uniforme : passe exactement par chaque point de contrôle,
// samplesPerSegment points intermédiaires entre chaque paire de points.
export function catmullRom(points: Vec2[], samplesPerSegment: number): Vec2[] {
if (points.length < 2) return points.slice();
const result: Vec2[] = [];
const at = (i: number) => points[Math.max(0, Math.min(points.length - 1, i))];
for (let i = 0; i < points.length - 1; i++) {
const p0 = at(i - 1);
const p1 = at(i);
const p2 = at(i + 1);
const p3 = at(i + 2);
for (let s = 0; s < samplesPerSegment; s++) {
const t = s / samplesPerSegment;
const t2 = t * t;
const t3 = t2 * t;
result.push({
x:
0.5 *
(2 * p1.x +
(-p0.x + p2.x) * t +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
y:
0.5 *
(2 * p1.y +
(-p0.y + p2.y) * t +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),
});
}
}
result.push(points[points.length - 1]);
return result;
}
-43
View File
@@ -1,43 +0,0 @@
import { GHOST_SAMPLE_INTERVAL_MS } from "./constants";
import type { GhostSample } from "./types";
// Ajoute un échantillon si assez de temps s'est écoulé depuis le dernier ;
// mutation en place, appelé à chaque frame depuis la boucle de jeu.
export function recordGhostSample(samples: GhostSample[], elapsedMs: number, x: number, y: number, a: number): void {
const last = samples[samples.length - 1];
if (!last || elapsedMs - last.t >= GHOST_SAMPLE_INTERVAL_MS) {
samples.push({ t: elapsedMs, x, y, a });
}
}
// Force un dernier échantillon exactement à l'arrivée (même hors cadence),
// pour que la relecture ne s'arrête jamais visiblement avant la ligne.
export function finishGhost(samples: GhostSample[], elapsedMs: number, x: number, y: number, a: number): void {
samples.push({ t: elapsedMs, x, y, a });
}
// Position interpolée d'un fantôme au temps écoulé donné ; reste figé à sa
// dernière position une fois son propre temps de course dépassé.
export function sampleGhostAt(path: GhostSample[], elapsedMs: number): GhostSample | null {
if (path.length === 0) return null;
if (elapsedMs <= path[0].t) return path[0];
const lastSample = path[path.length - 1];
if (elapsedMs >= lastSample.t) return lastSample;
for (let i = 0; i < path.length - 1; i++) {
const a = path[i];
const b = path[i + 1];
if (elapsedMs >= a.t && elapsedMs <= b.t) {
const span = b.t - a.t;
const t = span > 0 ? (elapsedMs - a.t) / span : 0;
return {
t: elapsedMs,
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
a: a.a + (b.a - a.a) * t,
};
}
}
return lastSample;
}
-112
View File
@@ -1,112 +0,0 @@
import {
CHARIOT_RADIUS,
FRICTION_WHEN_IDLE,
MAX_ACCEL,
MAX_SPEED,
SLOWDOWN_RECOVERY_MS,
SLOWDOWN_SPEED_FACTOR,
} from "./constants";
import { add, closestPointOnSegment, dot, length, normalize, scale, sub, type Vec2 } from "./geometry";
import type { ChariotState, InputVector, Track } from "./types";
function distanceToCenterline(point: Vec2, centerline: Vec2[]): { distance: number; closest: Vec2 } {
let best = Infinity;
let bestPoint = centerline[0];
for (let i = 0; i < centerline.length - 1; i++) {
const { point: candidate } = closestPointOnSegment(point, centerline[i], centerline[i + 1]);
const d = length(sub(point, candidate));
if (d < best) {
best = d;
bestPoint = candidate;
}
}
return { distance: best, closest: bestPoint };
}
// Vitesse max plafonnée après un choc : remonte progressivement de
// SLOWDOWN_SPEED_FACTOR à 1 sur SLOWDOWN_RECOVERY_MS — jamais d'arrêt net.
function speedCapFactor(lastCollisionAt: number | null, nowMs: number): number {
if (lastCollisionAt === null) return 1;
const sinceMs = nowMs - lastCollisionAt;
if (sinceMs >= SLOWDOWN_RECOVERY_MS) return 1;
const t = sinceMs / SLOWDOWN_RECOVERY_MS;
return SLOWDOWN_SPEED_FACTOR + (1 - SLOWDOWN_SPEED_FACTOR) * t;
}
export function createInitialState(track: Track): ChariotState {
return {
position: { ...track.start.position },
velocity: { x: 0, y: 0 },
heading: track.start.heading,
lastCollisionAt: null,
};
}
export function step(state: ChariotState, input: InputVector, track: Track, dtMs: number, nowMs: number): ChariotState {
const dt = dtMs / 1000;
const speedCap = speedCapFactor(state.lastCollisionAt, nowMs) * MAX_SPEED;
const inputMagnitude = Math.min(1, length(input));
const targetVelocity =
inputMagnitude > 0.001 ? scale(normalize(input), inputMagnitude * speedCap) : { x: 0, y: 0 };
const velocityDelta = sub(targetVelocity, state.velocity);
const maxStep = (inputMagnitude > 0.001 ? MAX_ACCEL : FRICTION_WHEN_IDLE) * dt;
const deltaLength = length(velocityDelta);
const appliedDelta = deltaLength > maxStep ? scale(normalize(velocityDelta), maxStep) : velocityDelta;
let velocity = add(state.velocity, appliedDelta);
let position = add(state.position, scale(velocity, dt));
let lastCollisionAt = state.lastCollisionAt;
// Murs : distance à la ligne centrale comparée à la demi-largeur du couloir.
const { distance, closest } = distanceToCenterline(position, track.centerline);
const wallLimit = track.halfWidth - CHARIOT_RADIUS;
if (distance > wallLimit) {
const outward = normalize(sub(position, closest));
position = add(closest, scale(outward, wallLimit));
const outwardComponent = dot(velocity, outward);
if (outwardComponent > 0) {
velocity = sub(velocity, scale(outward, outwardComponent));
}
lastCollisionAt = nowMs;
}
// Obstacles : cercles statiques.
for (const obstacle of track.obstacles) {
const toChariot = sub(position, obstacle.position);
const minDist = obstacle.radius + CHARIOT_RADIUS;
const dist = length(toChariot);
if (dist < minDist) {
const outward = dist > 0.001 ? normalize(toChariot) : { x: 1, y: 0 };
position = add(obstacle.position, scale(outward, minDist));
const outwardComponent = dot(velocity, outward);
if (outwardComponent < 0) {
velocity = sub(velocity, scale(outward, outwardComponent));
}
lastCollisionAt = nowMs;
}
}
const heading = length(velocity) > 5 ? Math.atan2(velocity.y, velocity.x) : state.heading;
return { position, velocity, heading, lastCollisionAt };
}
// Détecte le franchissement de la ligne d'arrivée entre deux positions
// consécutives (changement de signe de la position projetée sur la normale
// du segment d'arrivée, croisement vérifié dans les bornes du segment).
export function crossedFinish(prev: Vec2, next: Vec2, track: Track): boolean {
const { a, b } = track.finish;
const along = sub(b, a);
const normal = { x: -along.y, y: along.x };
const prevSide = dot(sub(prev, a), normal);
const nextSide = dot(sub(next, a), normal);
if (prevSide === 0 || Math.sign(prevSide) === Math.sign(nextSide)) return false;
const t = prevSide / (prevSide - nextSide);
const crossingPoint = add(prev, scale(sub(next, prev), t));
const alongLength = length(along);
const projected = dot(sub(crossingPoint, a), normalize(along));
return projected >= 0 && projected <= alongLength;
}
-23
View File
@@ -1,23 +0,0 @@
// PRNG déterministe (mulberry32) : même graine → même suite de nombres sur
// tous les appareils, condition nécessaire pour que la piste du jour soit
// identique pour tout le monde.
export function mulberry32(seed: number): () => number {
let a = seed;
return function random() {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// FNV-1a : transforme la date du jour ("2026-07-28") en graine numérique stable.
export function hashStringToSeed(value: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
-117
View File
@@ -1,117 +0,0 @@
import {
CORRIDOR_HALF_WIDTH,
MAX_TURN_RADIANS,
OBSTACLE_COUNT,
OBSTACLE_EDGE_BUFFER_RATIO,
OBSTACLE_LATERAL_MARGIN,
OBSTACLE_RADIUS,
SPLINE_SAMPLES_PER_SEGMENT,
WAYPOINT_COUNT,
WAYPOINT_STEP,
WORLD_HEIGHT,
WORLD_WIDTH,
} from "./constants";
import { add, catmullRom, normalize, rotate90, scale, sub, type Vec2 } from "./geometry";
import { hashStringToSeed, mulberry32 } from "./rng";
import type { Obstacle, Track } from "./types";
function generateRawWaypoints(rng: () => number): Vec2[] {
const points: Vec2[] = [{ x: 0, y: 0 }];
let heading = Math.PI / 2; // vers le bas (l'axe y de l'écran augmente vers le bas)
let current = points[0];
for (let i = 0; i < WAYPOINT_COUNT; i++) {
heading += (rng() - 0.5) * 2 * MAX_TURN_RADIANS;
current = add(current, {
x: Math.cos(heading) * WAYPOINT_STEP,
y: Math.sin(heading) * WAYPOINT_STEP,
});
points.push(current);
}
return points;
}
// Remet à l'échelle et recentre n'importe quelle marche aléatoire dans le
// rectangle jouable du monde (marge comprise) : garantit que la piste tient
// toujours dans l'écran fixe, quelle que soit la dérive de la marche aléatoire,
// sans avoir à régler un biais de rappel vers le centre au doigt mouillé.
function fitToWorld(points: Vec2[], margin: number): Vec2[] {
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const bboxWidth = Math.max(maxX - minX, 1e-6);
const bboxHeight = Math.max(maxY - minY, 1e-6);
const targetWidth = WORLD_WIDTH - margin * 2;
const targetHeight = WORLD_HEIGHT - margin * 2;
const scaleFactor = Math.min(targetWidth / bboxWidth, targetHeight / bboxHeight);
const bboxCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
const targetCenter = { x: WORLD_WIDTH / 2, y: WORLD_HEIGHT / 2 };
return points.map((p) => ({
x: (p.x - bboxCenter.x) * scaleFactor + targetCenter.x,
y: (p.y - bboxCenter.y) * scaleFactor + targetCenter.y,
}));
}
function placeObstacles(centerline: Vec2[], rng: () => number): Obstacle[] {
const n = centerline.length;
const startIndex = Math.floor(n * OBSTACLE_EDGE_BUFFER_RATIO);
const endIndex = Math.ceil(n * (1 - OBSTACLE_EDGE_BUFFER_RATIO));
const usable = Math.max(endIndex - startIndex, OBSTACLE_COUNT * 2);
const maxLateral = CORRIDOR_HALF_WIDTH - OBSTACLE_RADIUS - OBSTACLE_LATERAL_MARGIN;
const obstacles: Obstacle[] = [];
for (let i = 0; i < OBSTACLE_COUNT; i++) {
const slot = startIndex + Math.floor(((i + 0.5) / OBSTACLE_COUNT) * usable);
const jitter = Math.floor((rng() - 0.5) * (usable / OBSTACLE_COUNT) * 0.6);
const index = Math.max(1, Math.min(n - 2, slot + jitter));
const tangent = normalize(sub(centerline[index + 1], centerline[index - 1]));
const normal = rotate90(tangent);
const lateral = (rng() - 0.5) * 2 * maxLateral;
obstacles.push({
position: add(centerline[index], scale(normal, lateral)),
radius: OBSTACLE_RADIUS,
});
}
return obstacles;
}
// Fonction pure : la même dateKey (date du jour, Europe/Paris) produit
// toujours exactement la même piste, sans rien stocker côté serveur.
export function generateTrack(dateKey: string): Track {
const seed = hashStringToSeed(dateKey);
const rng = mulberry32(seed);
const margin = CORRIDOR_HALF_WIDTH + 20;
const waypoints = fitToWorld(generateRawWaypoints(rng), margin);
const centerline = catmullRom(waypoints, SPLINE_SAMPLES_PER_SEGMENT);
const obstacles = placeObstacles(centerline, rng);
const first = centerline[0];
const startHeading = Math.atan2(centerline[1].y - first.y, centerline[1].x - first.x);
const last = centerline[centerline.length - 1];
const beforeLast = centerline[centerline.length - 2];
const finishNormal = rotate90(normalize(sub(last, beforeLast)));
return {
seed,
centerline,
halfWidth: CORRIDOR_HALF_WIDTH,
obstacles,
start: { position: first, heading: startHeading },
finish: {
a: add(last, scale(finishNormal, CORRIDOR_HALF_WIDTH)),
b: add(last, scale(finishNormal, -CORRIDOR_HALF_WIDTH)),
},
world: { width: WORLD_WIDTH, height: WORLD_HEIGHT },
};
}
-25
View File
@@ -1,25 +0,0 @@
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 };
+30
View File
@@ -0,0 +1,30 @@
// Format classique Flappy Bird : le personnage reste à une position d'écran
// fixe, le décor défile de droite à gauche (pas de piste qui change de
// forme selon le jour — parcours procédural à chaque partie, sans graine
// partagée, puisqu'il n'y a plus de comparaison "même tracé pour tous").
export const VIEWPORT_WIDTH = 340;
export const VIEWPORT_HEIGHT = 500;
export const GROUND_HEIGHT = 36;
export const GROUND_Y = VIEWPORT_HEIGHT - GROUND_HEIGHT;
export const ICARUS_X = VIEWPORT_WIDTH * 0.3;
export const ICARUS_RADIUS = 12;
export const ICARUS_START_Y = VIEWPORT_HEIGHT / 2;
export const GRAVITY = 900; // unités/seconde²
export const FLAP_IMPULSE = -300; // vitesse verticale imposée à chaque battement d'aile
export const MAX_FALL_SPEED = 480;
export const TILT_MAX_RADIANS = (45 * Math.PI) / 180;
export const FORWARD_SPEED = 130; // unités/seconde, vitesse de défilement des colonnes
export const COLUMN_WIDTH = 46;
export const COLUMN_GAP = 128;
export const COLUMN_SPACING = 210;
export const COLUMN_GAP_MARGIN = 50; // distance mini entre le centre du trou et le sol/plafond
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 MIN_SCORE = 0;
export const MAX_SCORE = 1000000;
+37
View File
@@ -0,0 +1,37 @@
import {
COLUMN_GAP,
COLUMN_WIDTH,
GRAVITY,
GROUND_Y,
ICARUS_RADIUS,
ICARUS_START_Y,
ICARUS_X,
MAX_FALL_SPEED,
} from "./constants";
import type { Column, IcarusState } from "./types";
export function createInitialIcarus(): IcarusState {
return { y: ICARUS_START_Y, velocityY: 0 };
}
export function stepIcarus(state: IcarusState, dtMs: number): IcarusState {
const dt = dtMs / 1000;
const velocityY = Math.min(MAX_FALL_SPEED, state.velocityY + GRAVITY * dt);
const y = Math.max(0, state.y + velocityY * dt);
return { y, velocityY };
}
export function hasHitGround(icarus: IcarusState): boolean {
return icarus.y + ICARUS_RADIUS >= GROUND_Y;
}
export function collidesWithColumn(icarusY: number, column: Column): boolean {
const columnLeft = column.x;
const columnRight = column.x + COLUMN_WIDTH;
const overlapsX = columnRight > ICARUS_X - ICARUS_RADIUS && columnLeft < ICARUS_X + ICARUS_RADIUS;
if (!overlapsX) return false;
const gapTop = column.gapCenterY - COLUMN_GAP / 2;
const gapBottom = column.gapCenterY + COLUMN_GAP / 2;
return icarusY - ICARUS_RADIUS < gapTop || icarusY + ICARUS_RADIUS > gapBottom;
}
+3
View File
@@ -0,0 +1,3 @@
export type Column = { x: number; gapCenterY: number; scored: boolean };
export type IcarusState = { y: number; velocityY: number };
+1 -1
View File
@@ -8,7 +8,7 @@ const PROTECTED_PATHS = [
"/journal",
"/roulette",
"/calendrier",
"/course",
"/icare",
];
// /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