Corrige la distance du boost et ajoute des filets de vitesse continus
Build and deploy / deploy (push) Successful in 35s

La fenêtre de 3-8 colonnes du bouclier est maintenant une vraie distance :
chaque colonne rencontrée décompte, qu'elle soit touchée (cassée au lieu
de tuer) ou franchie sans contact (non cassée) — avant, seules les
colonnes touchées consommaient le bouclier, qui pouvait donc durer bien
plus longtemps que prévu.

Ajoute aussi des filets de vitesse continus autour d'Icare pendant toute
la durée du bouclier (pas seulement au bond initial de cueillette), pour
que la sensation d'accélération reste lisible même si Icare est figé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-23 23:52:44 +02:00
parent bdeeb68845
commit c3a3577769
3 changed files with 74 additions and 28 deletions
+66 -22
View File
@@ -70,9 +70,11 @@ function randomBoostInterval(): number {
);
}
// Nombre de colonnes cassables par ce boost, tiré à chaque cueillette
// (tirage entier inclusif des deux bornes).
function randomBoostCharges(): number {
// Distance du boost (en colonnes), tirée à chaque cueillette (tirage
// entier inclusif des deux bornes) : le bouclier dure le temps des N
// prochaines colonnes rencontrées, qu'elles soient cassées (touchées) ou
// simplement franchies sans contact — pas un budget de casses.
function randomBoostDistance(): number {
return BOOST_MIN_CHARGES + Math.floor(Math.random() * (BOOST_MAX_CHARGES - BOOST_MIN_CHARGES + 1));
}
@@ -236,6 +238,39 @@ function drawShatterEffect(ctx: CanvasRenderingContext2D, effect: ShatterEffect,
ctx.restore();
}
// Filets de vitesse continus tant que le bouclier est actif : Icare est
// figé (pas de mouvement propre), donc sans ça la sensation de vitesse ne
// venait que du défilement du décor — ces traits, qui naissent devant lui
// et filent vers l'arrière en boucle, rendent la vitesse lisible même à
// l'arrêt visuel d'Icare. Purement dérivé de "now" (pas de state), 6
// lignes déphasées pour ne pas paraître synchronisées.
function drawSpeedLines(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, now: number) {
const lineCount = 6;
const cycleMs = 260;
ctx.save();
ctx.strokeStyle = "#E7C560";
ctx.lineCap = "round";
ctx.lineWidth = 2;
for (let i = 0; i < lineCount; i++) {
const seed = i * 137;
const phase = ((now + seed) % cycleMs) / cycleMs;
const yOffset = ((i % 3) - 1) * 14 + (((i * 53) % 20) - 10);
const length = 14 + (i % 3) * 6;
const startX = centerX + 36 - phase * 100;
const alpha = 0.6 * Math.sin(phase * Math.PI);
if (alpha <= 0) continue;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(startX, centerY + yOffset);
ctx.lineTo(startX - length, centerY + yOffset);
ctx.stroke();
}
ctx.restore();
}
export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -256,7 +291,7 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
const boostsRef = useRef<Boost[]>([]);
const columnsSinceBoostRef = useRef(0);
const nextBoostThresholdRef = useRef(randomBoostInterval());
const boostChargesRef = useRef(0);
const boostColumnsRemainingRef = useRef(0);
const boostDashAtRef = useRef<number | null>(null);
const shatterEffectsRef = useRef<ShatterEffect[]>([]);
const nextEntityIdRef = useRef(0);
@@ -271,7 +306,7 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
boostsRef.current = [];
columnsSinceBoostRef.current = 0;
nextBoostThresholdRef.current = randomBoostInterval();
boostChargesRef.current = 0;
boostColumnsRemainingRef.current = 0;
boostDashAtRef.current = null;
shatterEffectsRef.current = [];
setStatus("idle");
@@ -354,7 +389,7 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
// gravité, les taps n'ont aucun effet) et le décor défile vite à
// sa place — impossible de toucher au mouvement pendant cette
// période.
if (boostChargesRef.current > 0) {
if (boostColumnsRemainingRef.current > 0) {
shouldFlap = false;
} else {
if (shouldFlap) {
@@ -371,7 +406,7 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
const baseSpeed = FORWARD_SPEED * (1 + (SPEED_MAX_MULTIPLIER - 1) * progress);
// Défilement accéléré tant que le bouclier du boost est actif
// (sans risque : une colonne touchée casse au lieu de tuer).
const currentSpeed = boostChargesRef.current > 0 ? baseSpeed * BOOST_SPEED_MULTIPLIER : baseSpeed;
const currentSpeed = boostColumnsRemainingRef.current > 0 ? baseSpeed * BOOST_SPEED_MULTIPLIER : baseSpeed;
const currentGap = COLUMN_GAP * (1 - (1 - GAP_MIN_MULTIPLIER) * progress);
for (const column of columnsRef.current) {
@@ -380,6 +415,11 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
column.scored = true;
scoreRef.current += 1;
setScore(scoreRef.current);
// Colonne franchie sans contact pendant le boost : compte
// quand même dans la distance du bouclier (voir plus bas).
if (boostColumnsRemainingRef.current > 0) {
boostColumnsRemainingRef.current -= 1;
}
}
}
columnsRef.current = columnsRef.current.filter((c) => c.x + COLUMN_WIDTH > 0);
@@ -418,26 +458,26 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
const collectedIndex = boostsRef.current.findIndex((b) => collidesWithBoost(icarusRef.current.y, b));
if (collectedIndex !== -1) {
boostsRef.current.splice(collectedIndex, 1);
// Active un bouclier d'un nombre aléatoire de 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.
// Icare se fige (vitesse verticale annulée, plus de gravité ni
// de prise en compte des taps tant que dure le bouclier, voir
// plus haut).
boostChargesRef.current = randomBoostCharges();
// Active le bouclier pour les N prochaines colonnes (distance
// aléatoire, voir plus bas) et déclenche l'animation de bond en
// avant. Icare se fige (vitesse verticale annulée, plus de
// gravité ni de prise en compte des taps tant que dure le
// bouclier, voir plus haut).
boostColumnsRemainingRef.current = randomBoostDistance();
boostDashAtRef.current = now;
icarusRef.current = { ...icarusRef.current, velocityY: 0 };
}
// 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.
// que la distance du bouclier n'est pas épuisée, une colonne
// touchée se casse au lieu de tuer et compte dans cette distance
// (comme une colonne franchie sans contact, voir plus haut) ;
// 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;
if (boostColumnsRemainingRef.current > 0) {
const broken = touchedColumns.slice(0, boostColumnsRemainingRef.current);
boostColumnsRemainingRef.current -= broken.length;
for (const column of broken) {
if (!column.scored) {
column.scored = true;
@@ -460,7 +500,7 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
}
}
if (boostChargesRef.current === 0 && hasHitGround(icarusRef.current)) {
if (boostColumnsRemainingRef.current === 0 && hasHitGround(icarusRef.current)) {
statusRef.current = "dead";
setStatus("dead");
onFinishRef.current(scoreRef.current);
@@ -502,7 +542,7 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
boostsRef.current,
shatterEffectsRef.current,
dashOffset,
boostChargesRef.current > 0,
boostColumnsRemainingRef.current > 0,
statusRef.current,
scoreRef.current,
wingAngle,
@@ -654,6 +694,10 @@ function render(
const icarusX = ICARUS_X + dashOffset;
if (boosted) {
drawSpeedLines(ctx, icarusX, icarus.y, now);
}
// Traînée de vitesse derrière Icare pendant le bond en avant.
if (dashOffset > 1) {
ctx.save();
+7 -5
View File
@@ -48,10 +48,12 @@ export const MAX_FRAME_DT_MS = 250; // évite la "spirale de la mort" après un
// 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.
// colonne qui le porte. Le ramasser active le bouclier pour les
// BOOST_MIN..MAX_CHARGES prochaines colonnes (distance, pas un budget de
// casses) : tant qu'il en reste, toucher une colonne (ce qui tuerait
// normalement) la casse à la place — mais une colonne franchie sans
// contact compte aussi dans cette distance, elle n'est pas cassée pour
// autant.
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
@@ -64,7 +66,7 @@ export const BOOST_MIN_COLUMN_INTERVAL = 15;
export const BOOST_MAX_COLUMN_INTERVAL = 25;
export const BOOST_MIN_CHARGES = 3;
export const BOOST_MAX_CHARGES = 8;
// Vitesse de défilement multipliée tant qu'il reste des charges (sans
// Vitesse de défilement multipliée tant que le bouclier est actif (sans
// risque : une colonne touchée casse au lieu de tuer pendant cette
// période) — renforce la sensation de vitesse du bouclier actif.
export const BOOST_SPEED_MULTIPLIER = 1.7;