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
+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. 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 un nombre aléatoire (3 à 8) de 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). Pendant que le bouclier est actif, Icare est immobile (gravité coupée, les taps/la barre d'espace n'ont plus d'effet — impossible d'agir sur la trajectoire) et le défilement est accéléré (×1,7) : il fonce tout droit à travers les colonnes jusqu'à épuisement des charges, où le contrôle normal reprend. 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.
- **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 active le bouclier pour une **distance** aléatoire (3 à 8 colonnes, pas un budget de casses) : chaque colonne rencontrée pendant cette fenêtre compte dans la distance, qu'elle soit touchée (elle casse alors au lieu de tuer, comptée comme passée) ou simplement franchie sans contact (elle n'est pas cassée pour autant). Pendant que le bouclier est actif, Icare est immobile (gravité coupée, les taps/la barre d'espace n'ont plus d'effet — impossible d'agir sur la trajectoire) et le défilement est accéléré (×1,7) : il fonce tout droit à travers les colonnes jusqu'à épuisement de la distance, où le contrôle normal reprend. 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.
+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;