Ajoute un boost (éclair de Zeus) et le support clavier au Vol d'Icare
Build and deploy / deploy (push) Successful in 35s

Rare et exceptionnel (intervalle large de 15-25 colonnes, pas de plafond
par partie) : ramasser l'éclair donne un bouclier de 5 charges — toucher
une colonne (mortel normalement) la casse au contact et consomme une
charge, une par une, plutôt que de détruire un nombre fixe de colonnes
d'un coup à la cueillette. Cueillette animée d'un bond en avant d'Icare
avec traînée de vitesse, halo pulsant sur l'éclair, éclat de particules
sur chaque colonne cassée et liseré doré tant que le bouclier est actif.

Ajoute aussi le support clavier (barre d'espace = tap) pour jouer au
clavier sur ordinateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-23 23:29:19 +02:00
parent 47ca05f1c6
commit b6a025c23f
5 changed files with 299 additions and 17 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ V1 à V10 sont livrées :
- **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é — déplacée dans `/char` depuis, voir V7), 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, **Le Vol d'Icare** (`/icare`) — Flappy Bird grec (Icare vole entre des colonnes de temple), record personnel all-time (pas de piste par jour), touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro, comme l'original). Les scores sont figés dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte) — seul mécanisme de points de tout le projet qui ne passe pas par une décision de juge.
- **V6** : premier mini-jeu compétitif, **Le Vol d'Icare** (`/icare`) — Flappy Bird grec (Icare vole entre des colonnes de temple), record personnel all-time (pas de piste par jour), touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro, comme l'original). Les scores sont figés dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte) — seul mécanisme de points de tout le projet qui ne passe pas par une décision de juge. Un boost (« éclair de Zeus »), rare et exceptionnel (intervalle large de 15 à 25 colonnes, pas de plafond par partie — une partie moyenne, ~50 de score, n'en voit naturellement que 1 à 2), apparaît au centre du trou de la colonne qui le porte (donc toujours atteignable) et donne 5 charges de bouclier plutôt que de détruire des colonnes instantanément : tant qu'il reste des charges, toucher une colonne (ce qui tuerait normalement) la casse à la place et consomme une charge, une par une au contact — comptée comme passée (le boost récompense plutôt qu'il ne coûte du score). Cueillette animée d'un bond en avant d'Icare (traînée de vitesse), halo pulsant sur l'éclair, éclat de particules sur chaque colonne cassée et fin liseré doré autour d'Icare tant que le bouclier est actif — entièrement dessiné sur canvas (`src/app/icare/icarus-game.tsx`), sans state React par frame.
- **V7** : outils numériques pour le vrai jeu du Tribunal (soirée physique/sociale, pas un mini-jeu digital), page **Le Jeu de l'Agora** (`/char`) — pensée **format paysage 16:9** (projetée sur grand écran) plutôt que mobile-first comme le reste de l'app. **Le Char** : 2 colonnes fixes de 3 emplacements affichées côte à côte avec un « VS » et une illustration de char vu du dessus entre les deux, affectation d'un Citoyen à un emplacement vide en un clic (liste des Citoyens libres directement cliquables, pas de menu déroulant), bouton « Retirer », boutons « Colonne A/B remporte le duel » (vide la colonne gagnante après une brève animation — les vainqueurs sortent, les perdants restent), compteur du nombre de fois que chacun est monté dans le char, avec un petit classement récapitulatif en bas de page (tout le monde, y compris ceux qui n'y sont jamais monté). La banque de questions (ajout/édition/réordonnancement/bascule afficher-masquer) vit sur une page séparée réservée aux Archontes, **`/char/questions`** — pensée pour être pilotée depuis un téléphone pendant que `/char` est projetée depuis un ordinateur ; `/char` n'affiche que la question actuellement révélée, en lecture seule. **Le Gardien du Silence** : rôle tiré au sort parmi les Citoyens (jamais les Archontes), affiché de façon discrète (barre compacte, pas une grande carte), minuteur avec reroll automatique à expiration (RPC auto-protégée, filet `pg_cron`) et bouton de reroll manuel pour les Archontes. L'app n'arbitre jamais le jeu lui-même, elle affiche/synchronise un état piloté à la main. **La Roulette** (tirage au sort animé parmi les membres, ou les Citoyens uniquement), initialement livrée en V5 sur sa propre page `/roulette`, a été fusionnée dans `/char` comme troisième section — la route et le lien de menu dédiés ont été retirés, tout l'outillage de la soirée du Tribunal vit maintenant au même endroit.
- **V8** : les Citoyens peuvent désormais proposer une question pour Le Char — une par personne (upsert), modifiable autant de fois que voulu depuis une nouvelle section sur `/profile`, jusqu'à ce que la date du Tribunal (`settings.tribunal_date`) soit atteinte (même mécanisme de gel que les scores d'Icare, no-op silencieux plutôt qu'une erreur). Les Archontes modèrent ces suggestions depuis une nouvelle section « Suggestions des Citoyens » sur `/char/questions` : chaque proposition affiche l'auteur, avec un bouton « Ajouter à la banque » (la copie dans `chariot_questions`, à la fin de l'ordre existant) et un bouton de rejet (suppression, confirmation à deux temps comme le reste de la page). Par ailleurs, `/admin` affiche maintenant l'adresse email de chaque membre (RPC `admin_list_members()`, seul moyen d'exposer `auth.users.email` aux juges sans passer par la `service_role key` côté client).
- **V9** : **Le Mur de la Honte** (`/mur`) — chaque Citoyen peut publier une note anonyme par jour (dix places maximum sur le mur, remis à zéro chaque jour — pas de suppression, juste un filtrage par date). L'anonymat n'est qu'à moitié réel : les Archontes voient une section « Historique complet » (filtrable par jour) avec l'auteur de chaque note jamais publiée, cohérent avec le thème du site (les Archontes ont un ascendant sur les Citoyens). Pas de gel lié à la date du Tribunal : contrairement au Char, c'est une mécanique de toute la semaine, pas de la seule soirée de clôture.
+250 -13
View File
@@ -1,7 +1,13 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
BOOST_CHARGES,
BOOST_DASH_DISTANCE,
BOOST_DASH_MS,
BOOST_MAX_COLUMN_INTERVAL,
BOOST_MIN_COLUMN_INTERVAL,
BOOST_RADIUS,
COLUMN_GAP,
COLUMN_GAP_MARGIN,
COLUMN_SPACING,
@@ -16,6 +22,7 @@ import {
ICARUS_RADIUS,
ICARUS_X,
MAX_FRAME_DT_MS,
SHATTER_ANIM_MS,
SPEED_MAX_MULTIPLIER,
TILT_MAX_RADIANS,
VIEWPORT_HEIGHT,
@@ -26,8 +33,14 @@ import {
WING_IDLE_PERIOD_MS,
WING_REST_RADIANS,
} from "@/lib/icarus/constants";
import { collidesWithColumn, createInitialIcarus, hasHitGround, stepIcarus } from "@/lib/icarus/physics";
import type { Column, IcarusState } from "@/lib/icarus/types";
import {
collidesWithBoost,
collidesWithColumn,
createInitialIcarus,
hasHitGround,
stepIcarus,
} from "@/lib/icarus/physics";
import type { Boost, Column, IcarusState, ShatterEffect } from "@/lib/icarus/types";
type Status = "idle" | "flying" | "dead";
@@ -46,6 +59,15 @@ function randomGapCenter(gap: number): number {
return minCenter + Math.random() * Math.max(0, maxCenter - minCenter);
}
// Prochain boost dans BOOST_MIN..MAX_COLUMN_INTERVAL colonnes (tirage
// entier inclusif des deux bornes).
function randomBoostInterval(): number {
return (
BOOST_MIN_COLUMN_INTERVAL +
Math.floor(Math.random() * (BOOST_MAX_COLUMN_INTERVAL - BOOST_MIN_COLUMN_INTERVAL + 1))
);
}
// Une aile, dessinée pointant vers -x ; side=-1 la reflète pour l'aile
// opposée. scale(side,1) inverse déjà le sens de rotation pour le côté
// reflété — passer le même wingAngle (sans le renverser à la main) aux deux
@@ -75,11 +97,28 @@ function drawWing(ctx: CanvasRenderingContext2D, side: 1 | -1, wingAngle: number
ctx.restore();
}
function drawIcarus(ctx: CanvasRenderingContext2D, y: number, tilt: number, wingAngle: number) {
function drawIcarus(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
tilt: number,
wingAngle: number,
boosted: boolean,
) {
ctx.save();
ctx.translate(ICARUS_X, y);
ctx.translate(x, y);
ctx.rotate(tilt);
if (boosted) {
ctx.save();
ctx.strokeStyle = "#E7C560";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, ICARUS_RADIUS + 5, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
ctx.fillStyle = "#E7C560";
ctx.beginPath();
ctx.ellipse(0, 0, ICARUS_RADIUS * 0.85, ICARUS_RADIUS, 0, 0, Math.PI * 2);
@@ -129,6 +168,66 @@ function drawColumn(ctx: CanvasRenderingContext2D, column: Column) {
drawColumnShaft(ctx, column.x, gapBottom, GROUND_Y - gapBottom, false);
}
// Éclair de Zeus ramassable : halo pulsant + éclair sombre par-dessus, pour
// rester lisible sur le halo doré.
function drawBoost(ctx: CanvasRenderingContext2D, boost: Boost, now: number) {
const pulse = 0.5 + 0.5 * Math.sin(now / 200);
ctx.save();
ctx.translate(boost.x, boost.y);
const glow = ctx.createRadialGradient(0, 0, 2, 0, 0, BOOST_RADIUS + 6 + pulse * 3);
glow.addColorStop(0, "#E7C560");
glow.addColorStop(1, "rgba(231, 197, 96, 0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(0, 0, BOOST_RADIUS + 6 + pulse * 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#C9A227";
ctx.beginPath();
ctx.arc(0, 0, BOOST_RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#0A1B33";
ctx.beginPath();
ctx.moveTo(-2, -9);
ctx.lineTo(4, -1);
ctx.lineTo(0, -1);
ctx.lineTo(3, 9);
ctx.lineTo(-5, 0);
ctx.lineTo(-1, 0);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// Fragments qui s'écartent du centre d'une colonne détruite par un boost,
// en s'estompant — purement décoratif, dérivé du temps écoulé (pas de
// state React par effet).
function drawShatterEffect(ctx: CanvasRenderingContext2D, effect: ShatterEffect, now: number) {
const progress = Math.min(1, (now - effect.createdAtMs) / SHATTER_ANIM_MS);
if (progress >= 1) return;
ctx.save();
ctx.translate(effect.x, effect.y);
ctx.globalAlpha = 1 - progress;
ctx.strokeStyle = "#E7C560";
ctx.lineWidth = 2;
const pieces = 8;
for (let i = 0; i < pieces; i++) {
const angle = (i / pieces) * Math.PI * 2;
const dist = progress * 34;
ctx.beginPath();
ctx.moveTo(Math.cos(angle) * dist, Math.sin(angle) * dist);
ctx.lineTo(Math.cos(angle) * (dist + 9), Math.sin(angle) * (dist + 9));
ctx.stroke();
}
ctx.restore();
}
export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -146,19 +245,32 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
const scoreRef = useRef(0);
const flapRequestedRef = useRef(false);
const lastFlapAtRef = useRef<number | null>(null);
const boostsRef = useRef<Boost[]>([]);
const columnsSinceBoostRef = useRef(0);
const nextBoostThresholdRef = useRef(randomBoostInterval());
const boostChargesRef = useRef(0);
const boostDashAtRef = useRef<number | null>(null);
const shatterEffectsRef = useRef<ShatterEffect[]>([]);
const nextEntityIdRef = useRef(0);
function resetGame() {
const resetGame = useCallback(() => {
icarusRef.current = createInitialIcarus();
columnsRef.current = [];
scoreRef.current = 0;
statusRef.current = "idle";
flapRequestedRef.current = false;
lastFlapAtRef.current = null;
boostsRef.current = [];
columnsSinceBoostRef.current = 0;
nextBoostThresholdRef.current = randomBoostInterval();
boostChargesRef.current = 0;
boostDashAtRef.current = null;
shatterEffectsRef.current = [];
setStatus("idle");
setScore(0);
}
}, []);
function requestFlap() {
const requestFlap = useCallback(() => {
flapRequestedRef.current = true;
if (statusRef.current === "idle") {
statusRef.current = "flying";
@@ -166,7 +278,21 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
} else if (statusRef.current === "dead") {
resetGame();
}
}, [resetGame]);
// Sur ordinateur (pas de tap tactile) : la barre d'espace fait la même
// chose qu'un tap. event.repeat ignoré pour ne pas enchaîner des
// battements tant que la touche reste appuyée (même sémantique qu'un tap
// isolé) ; preventDefault pour empêcher le défilement de la page.
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.code !== "Space" || event.repeat) return;
event.preventDefault();
requestFlap();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [requestFlap]);
useEffect(() => {
const canvas = canvasRef.current;
@@ -241,16 +367,77 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
const last = columnsRef.current[columnsRef.current.length - 1];
if (!last || last.x < VIEWPORT_WIDTH - COLUMN_SPACING) {
columnsRef.current.push({
const spawned: Column = {
x: last ? last.x + COLUMN_SPACING : VIEWPORT_WIDTH,
gapCenterY: randomGapCenter(currentGap),
gap: currentGap,
scored: false,
};
columnsRef.current.push(spawned);
// Rare et exceptionnel : intervalle large (voir constants.ts),
// pas de plafond dur — placé au centre du trou de la colonne
// qui le porte, toujours atteignable en volant simplement au
// milieu du passage.
columnsSinceBoostRef.current += 1;
if (columnsSinceBoostRef.current >= nextBoostThresholdRef.current) {
columnsSinceBoostRef.current = 0;
nextBoostThresholdRef.current = randomBoostInterval();
boostsRef.current.push({
id: nextEntityIdRef.current++,
x: spawned.x + COLUMN_WIDTH / 2,
y: spawned.gapCenterY,
});
}
}
const hitColumn = columnsRef.current.some((c) => collidesWithColumn(icarusRef.current.y, c));
if (hasHitGround(icarusRef.current) || hitColumn) {
for (const boost of boostsRef.current) {
boost.x -= currentSpeed * (FIXED_DT_MS / 1000);
}
boostsRef.current = boostsRef.current.filter((b) => b.x + BOOST_RADIUS > 0);
const collectedIndex = boostsRef.current.findIndex((b) => collidesWithBoost(icarusRef.current.y, b));
if (collectedIndex !== -1) {
boostsRef.current.splice(collectedIndex, 1);
// Active un bouclier de BOOST_CHARGES charges (voir plus bas) et
// déclenche l'animation de bond en avant — la casse des
// colonnes ne se fait plus d'un coup à la cueillette, elle se
// fait au contact, une par une, tant qu'il reste des charges.
boostChargesRef.current = BOOST_CHARGES;
boostDashAtRef.current = now;
}
// Colonnes réellement touchées (mortelles en temps normal). Tant
// qu'il reste des charges de boost, une colonne touchée se casse
// au lieu de tuer et consomme une charge ; sinon mort normale.
const touchedColumns = columnsRef.current.filter((c) => collidesWithColumn(icarusRef.current.y, c));
if (touchedColumns.length > 0) {
if (boostChargesRef.current > 0) {
const broken = touchedColumns.slice(0, boostChargesRef.current);
boostChargesRef.current -= broken.length;
for (const column of broken) {
if (!column.scored) {
column.scored = true;
scoreRef.current += 1;
}
shatterEffectsRef.current.push({
id: nextEntityIdRef.current++,
x: column.x + COLUMN_WIDTH / 2,
y: column.gapCenterY,
createdAtMs: now,
});
}
columnsRef.current = columnsRef.current.filter((c) => !broken.includes(c));
setScore(scoreRef.current);
} else {
statusRef.current = "dead";
setStatus("dead");
onFinishRef.current(scoreRef.current);
break;
}
}
if (hasHitGround(icarusRef.current)) {
statusRef.current = "dead";
setStatus("dead");
onFinishRef.current(scoreRef.current);
@@ -261,6 +448,14 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
}
}
shatterEffectsRef.current = shatterEffectsRef.current.filter((e) => now - e.createdAtMs < SHATTER_ANIM_MS);
// Bond en avant à la cueillette : aller-retour (pic à mi-parcours),
// pas un déplacement qui persiste au-delà de l'animation.
const dashElapsed = boostDashAtRef.current === null ? Infinity : now - boostDashAtRef.current;
const dashProgress = Math.min(1, dashElapsed / BOOST_DASH_MS);
const dashOffset = dashProgress >= 1 ? 0 : Math.sin(dashProgress * Math.PI) * BOOST_DASH_DISTANCE;
// Battement continu en boucle (se voit même sans taper, dès l'écran de
// repos) : oscille doucement entre le repos et un pic modéré.
const idlePhase = (now % WING_IDLE_PERIOD_MS) / WING_IDLE_PERIOD_MS;
@@ -276,7 +471,20 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
const wingAngle = statusRef.current === "dead" ? WING_REST_RADIANS : Math.max(idleWingAngle, tapWingAngle);
render(ctx!, canvas!, icarusRef.current, columnsRef.current, statusRef.current, scoreRef.current, wingAngle);
render(
ctx!,
canvas!,
icarusRef.current,
columnsRef.current,
boostsRef.current,
shatterEffectsRef.current,
dashOffset,
boostChargesRef.current > 0,
statusRef.current,
scoreRef.current,
wingAngle,
now,
);
rafId = requestAnimationFrame(frame);
}
@@ -384,9 +592,14 @@ function render(
canvas: HTMLCanvasElement,
icarus: IcarusState,
columns: Column[],
boosts: Boost[],
shatterEffects: ShatterEffect[],
dashOffset: number,
boosted: boolean,
status: Status,
score: number,
wingAngle: number,
now: number,
) {
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
@@ -405,11 +618,35 @@ function render(
drawColumn(ctx, column);
}
for (const boost of boosts) {
drawBoost(ctx, boost, now);
}
for (const effect of shatterEffects) {
drawShatterEffect(ctx, effect, now);
}
ctx.fillStyle = "#2A2116";
ctx.fillRect(0, GROUND_Y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT - GROUND_Y);
const icarusX = ICARUS_X + dashOffset;
// Traînée de vitesse derrière Icare pendant le bond en avant.
if (dashOffset > 1) {
ctx.save();
ctx.translate(icarusX, icarus.y);
for (let i = 1; i <= 3; i++) {
ctx.globalAlpha = (dashOffset / BOOST_DASH_DISTANCE) * (0.3 / i);
ctx.fillStyle = "#E7C560";
ctx.beginPath();
ctx.ellipse(-i * 10, 0, ICARUS_RADIUS * 0.7, ICARUS_RADIUS * 0.5, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
const tilt = Math.max(-1, Math.min(1, icarus.velocityY / 300)) * TILT_MAX_RADIANS;
drawIcarus(ctx, icarus.y, tilt, wingAngle);
drawIcarus(ctx, icarusX, icarus.y, tilt, wingAngle, boosted);
ctx.restore();
+23
View File
@@ -46,5 +46,28 @@ export const GAP_MIN_MULTIPLIER = 0.75;
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
// Boost (éclair de Zeus) : apparaît tous les BOOST_MIN..MAX_COLUMN_INTERVAL
// colonnes (tirage aléatoire à chaque fois), au centre du trou de la
// colonne qui le porte. Le ramasser donne BOOST_CHARGES "charges" : tant
// qu'il en reste, toucher une colonne (ce qui tuerait normalement) la
// casse à la place et consomme une charge, plutôt que de détruire
// instantanément un nombre fixe de colonnes à la cueillette.
export const BOOST_RADIUS = 14; // taille du rond dessiné
// Rayon de ramassage réel, plus généreux que le rond dessiné : sinon seul
// un vol quasi pile au centre du trou le ramassait, malgré une fréquence
// d'apparition correcte (vérifié par simulation).
export const BOOST_COLLECT_RADIUS = 34;
// Rare et exceptionnel plutôt que fréquent : intervalle large, pas de
// plafond dur — une partie moyenne (~50 de score) n'en voit naturellement
// que 1 à 2, mais une partie plus longue peut légitimement en voir plus.
export const BOOST_MIN_COLUMN_INTERVAL = 15;
export const BOOST_MAX_COLUMN_INTERVAL = 25;
export const BOOST_CHARGES = 5;
// Animation de cueillette : Icare fait un bond en avant (aller-retour,
// pic à mi-parcours) plutôt que le halo statique d'avant.
export const BOOST_DASH_MS = 350;
export const BOOST_DASH_DISTANCE = 22;
export const SHATTER_ANIM_MS = 450;
export const MIN_SCORE = 0;
export const MAX_SCORE = 1000000;
+13 -1
View File
@@ -1,4 +1,5 @@
import {
BOOST_COLLECT_RADIUS,
COLUMN_WIDTH,
GRAVITY,
GROUND_Y,
@@ -7,7 +8,7 @@ import {
ICARUS_X,
MAX_FALL_SPEED,
} from "./constants";
import type { Column, IcarusState } from "./types";
import type { Boost, Column, IcarusState } from "./types";
export function createInitialIcarus(): IcarusState {
return { y: ICARUS_START_Y, velocityY: 0 };
@@ -34,3 +35,14 @@ export function collidesWithColumn(icarusY: number, column: Column): boolean {
const gapBottom = column.gapCenterY + column.gap / 2;
return icarusY - ICARUS_RADIUS < gapTop || icarusY + ICARUS_RADIUS > gapBottom;
}
// Zone de ramassage volontairement plus large que le rond dessiné
// (BOOST_RADIUS) : à taille réelle, seul un vol quasi pile au centre du
// trou l'aurait touché, ce qui le rendait ramassé bien plus rarement que
// prévu malgré une fréquence d'apparition correcte.
export function collidesWithBoost(icarusY: number, boost: Boost): boolean {
const dx = boost.x - ICARUS_X;
const dy = boost.y - icarusY;
const combined = ICARUS_RADIUS + BOOST_COLLECT_RADIUS;
return dx * dx + dy * dy < combined * combined;
}
+10
View File
@@ -4,3 +4,13 @@
export type Column = { x: number; gapCenterY: number; gap: number; scored: boolean };
export type IcarusState = { y: number; velocityY: number };
// Éclair de Zeus ramassable, placé au centre du trou d'une colonne (donc
// toujours atteignable sans effort de vol supplémentaire) : casse les
// prochaines colonnes non encore franchies.
export type Boost = { id: number; x: number; y: number };
// Effet visuel transitoire (éclat d'une colonne détruite par un boost),
// horodaté avec le "now" de requestAnimationFrame pour calculer sa
// progression/disparition au rendu, sans state React ni re-render.
export type ShatterEffect = { id: number; x: number; y: number; createdAtMs: number };