Ajoute un boost (éclair de Zeus) et le support clavier au Vol d'Icare
Build and deploy / deploy (push) Successful in 35s
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:
+252
-15
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user