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
+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();
}