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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user