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:
@@ -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'écran pour battre des ailes et t'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();
|
||||
}
|
||||
Reference in New Issue
Block a user