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