Skip to content

Vues du plateau

Claude (assistant) edited this page Aug 8, 2026 · 18 revisions

Vues du plateau — 1D, 2D, 3D

Le plateau se regarde de trois façons, sélectionnables par la bascule en haut à gauche, dans l'ordre des dimensions : 1D · 2D · 3D. Elles ne sont pas trois programmes différents : ce sont trois rendus d'un seul et même état de jeu.

1D 2D 3D
Support <pre> texte <canvas> <canvas> WebGL (Three.js)
Rendu 61 caractères dessin complet, skins, animations scène éclairée, caméra orbitale
Jouable oui oui oui
Hors-ligne oui oui oui (Three.js auto-hébergé)

Le principe : un seul point d'entrée

La règle qui tient l'ensemble est simple et vaut d'être rappelée à quiconque ajoute une vue :

Une vue ne connaît aucune règle du jeu. Elle désigne une case, rien de plus.

Concrètement, la 1D comme la 2D aboutissent toutes deux à handleClick(r, c). C'est là — et là seulement — que vivent la sélection, la validation du coup, l'application et le score. Une vue qui réimplémenterait ne serait-ce que la contrainte d'alignement de la sélection finirait tôt ou tard par diverger du moteur, en silence.

La synchronisation entre vues n'a donc pas besoin d'être écrite : handleClick se termine par drawBoard(), qui rafraîchit au passage la 1D et la 3D si elles sont actives. Jouer un coup dans une dimension le fait apparaître dans les deux autres. On peut changer de vue en pleine partie sans rien perdre.

Les verrous d'interaction sont eux aussi partagés, et doivent être répliqués à l'identique par toute vue jouable :

  • partie terminée
  • navigation en replay — sauf en exploration de variante
  • tour de l'IA (gameMode === 'ai' && currentTurn !== humanColor)

En 1D, _1dCanPlay() les applique et la vue affiche (lecture seule) quand l'un d'eux tombe.

La vue 1D

Le plateau en caractères, monochrome, sur fond noir — l'esprit d'une invite de commande. Utile pour lire une position sans distraction, la copier dans un message, ou jouer au clavier-souris sans quitter le texte.

 I     O O O O O
 H    O O O O O O
 G   . . O O O . .
 F  . . . . . . . .
 E . . . . . . . . .
 D  . . . . . . . .
 C   . . @ @ @ . .
 B    @ @ @ @ @ @
 A     @ @ @ @ @

   O = noirs   @ = blancs   . = case vide

Deux contraintes de rendu, non négociables :

  • Aucun caractère ajouté pour signaler un état. Une sélection marquée par des crochets décalerait la rangée et casserait l'alignement des neuf lignes. La sélection est donc rendue en vidéo inverse, la convention du terminal, qui n'occupe aucune colonne supplémentaire.
  • La zone de clic est élargie sans déplacer quoi que ce soit. Une lettre seule fait une cible minuscule au doigt ; le rembourrage qui l'agrandit est compensé par une marge négative de valeur égale. La grille ne bouge pas d'un pixel.

Les options de rendu de drawBoard

drawBoard(opts) sert tous les plateaux de l'application : la partie, l'Éditeur, les Puzzles, l'Analyse, le Tutoriel. Certaines couches qu'il dessine appartiennent pourtant à la partie en cours et n'ont aucun sens ailleurs. D'où ce contrat, à respecter par tout nouveau plateau :

Option Effet Qui l'utilise
canvasId plateau cible tous
board, selected, turn état à dessiner tous
hideGutter masque la gouttière Tutoriel
gutterCounts impose le contenu de la gouttière : {black, white} Analyse
puzzleCaptured gouttière du mode puzzle Puzzles
hideGameOverlays supprime flèche du dernier coup et halos de menace Éditeur, Puzzles, Analyse, Tutoriel

Le piège, rencontré deux fois. drawLastMoveArrow lit boardSnapshots et drawThreats lit le plateau global : appliqués sans condition, ils affichent l'état de la partie précédente sur un plateau qui n'a rien à voir. Le même défaut s'était produit avec les billes éjectées, réglé par hideGutter ; il s'est reproduit tel quel avec la flèche, faute d'avoir généralisé le principe. D'où la règle :

Toute couche qui lit un état global de la partie doit être conditionnée par une option, et cette option activée par défaut sur les plateaux qui ne sont pas la partie.

Billes éjectées : deux affichages

Deux lectures légitimes de la gouttière coexistent, au choix dans Paramètres :

  • à leur point de sortie (défaut) — chaque bille reste là où elle a quitté le plateau, ce qui montre d'un coup d'œil par où l'adversaire a percé ;
  • rangées en bas — alignées dans l'ordre, plus rapides à compter.

En replay, l'affichage ne dépend d'aucun de ces deux modes : la gouttière est reconstruite depuis les compteurs du snapshot affiché. gutterMarbles est un cumul de la partie entière, vidé seulement par resetGutterPositions() — s'y fier en replay revenait à montrer le score final sur toutes les positions.

Indices de coups : un calcul, deux vues

Comme le rendu, le calcul des cases jouables ne doit exister qu'à un seul endroit. computeMoveHints(sel, turn, board) et computePlayableCells(turn, board) vivent en dehors de drawBoard et sont appelées à l'identique par la 2D et par la 1D — la 1D ne réimplémente rien, elle demande au même moteur et se contente de colorer la réponse.

Ce point a été raté une première fois : le calcul vivait en clair dans drawBoard, écrit pour la 2D uniquement. L'extraire sans le réécrire demandait une preuve, pas une relecture — un test compare l'ancien algorithme et la nouvelle fonction sur six scénarios (bille seule, trio en ligne, coup en flèche, directions multiples) avant de considérer l'extraction sûre.

En 1D, les couleurs reprennent exactement celles de la 2D : vert (#4a9463) pour une case libre où la sélection peut aller, rouge (#e05c4b) pour une bille adverse qu'elle peut pousser. Une règle stricte s'y ajoute :

Une bille n'est jamais teintée ni surlignée pour signaler qu'elle peut jouer. Sa couleur ne dit qu'une chose — noire ou blanche — et rien d'autre ne doit s'y superposer.

Un premier essai enfreignait cette règle : les billes ayant au moins un coup légal étaient surlignées en vert avant sélection, sur le modèle du Mode Enfant en 2D (computePlayableCells). Retiré sur remarque directe : recolorer une pièce la rend illisible en tant que pièce. Un test dédié échoue si la règle CSS correspondante (.c1d.jouable, lg-jouable) réapparaît dans le fichier — computePlayableCells reste disponible pour le Mode Enfant en 2D, la 1D ne l'appelle simplement plus.

Repères de coordonnées en 1D

Activés par le même réglage qu'en 2D (Aides → Coordonnées des cases), pas par un nouveau. Les lettres A–I sont directes ; les chiffres 1–9 demandent plus de soin, parce que la notation Aba-Pro n'est pas une grille cartésienne : ce sont des diagonales nord-ouest/sud-est, chacune repérée par son extrémité bas-droite.

Concrètement, la diagonale 9 se termine en e9 : son repère se pose donc au prolongement de cette case précise — une ligne plus bas, une colonne plus à droite — pas à l'aplomb de la rangée E. Se fier à « 9 contre la rangée E » (ce qu'on ferait sur une grille ordinaire) place le chiffre une ligne trop haut. Chaque repère est vérifié contre le vrai convertisseur coordToABAPRO, jamais contre une colonne recopiée à la main — c'est ce contrôle qui a révélé l'erreur de placement initiale.

Les neuf repères dessinent ainsi les deux arêtes de l'angle bas-droit du plateau, qui se rejoignent sur le 5 : 1 à 5 en pied, au pas de deux colonnes ; 5 à 9 le long du bord, un cran par rangée. Le bord des repères reste parallèle au bord des cases — lettre et chiffre toujours à égale distance de la dernière case de leur rangée.

Vue 3D : le picking

La 3D est jouable. Comme les deux autres vues, elle ne fait que résoudre une interaction en (r, c) puis appeler handleClick(r, c) — le point d'entrée reste unique.

Cibles de picking dédiées, pas les meshes visibles. Une tentation naturelle est de raycaster directement les trous et les billes affichés à l'écran. Deux raisons d'avoir préféré un plan invisible par case (PlaneGeometry, tagué userData = {r, c}), construit une fois et jamais reconstruit :

  • Indépendance de l'état du plateau. Une case vide (trou) et une case occupée (bille) sont deux géométries différentes ; une cible constante évite de recombiner deux tableaux à chaque clic, et reste valable même entre deux appels de sync().
  • Un défaut géométrique réel, trouvé en testant avec un vrai moteur Three.js (Raycaster fonctionne en pur calcul, sans GPU — testable en Node). Le trou est un cylindre à capuchon en éventail de triangles, qui partagent un sommet central. Viser exactement le centre mathématique d'une case — ce qu'un joueur fait naturellement — pouvait rater ce sommet par un pur artefact flottant, dans un test qui rejouait un clic pixel-parfait au centre recalculé d'une case. Un plan (quatre sommets de coin, aucun sommet partagé) n'a pas ce défaut.

Tap contre glissé de caméra. Le seuil est le même qu'en 2D (18 px de déplacement entre pointerdown et pointerup). En-deçà, c'est un tap : la case sous le pointeur est résolue et envoyée à handleClick. Au-delà, c'est un glisser de caméra, et rien n'est envoyé — sinon la moindre rotation de vue sélectionnerait une bille au passage. La rotation elle-même n'est jamais bloquée par ce seuil : même un tap fait déjà avancer imperceptiblement la caméra, comme sur les deux autres vues aucune interaction n'a d'effet de bord cosmétique inattendu.

Sélection et indices, mêmes fonctions que la 1D. computeMoveHints et le tableau global selected pilotent des anneaux et des disques ajoutés à côté des billes (anneau doré = sélection, anneau rouge = poussée possible, disque vert = déplacement possible) — jamais une recoloration du matériau d'une bille. C'est l'exact analogue 3D de la règle déjà posée pour la 1D : une bille ne dit qu'une chose, noire ou blanche, et rien d'autre ne s'y superpose.

window.canInteractWithBoard() (les mêmes trois verrous que partout ailleurs — partie finie, replay hors exploration de variante, tour de l'IA) est le nom canonique depuis que la 3D en a eu besoin ; window._1dCanPlay reste un alias.

Une seule boîte pour les trois vues

Les trois vues doivent occuper exactement le même espace à l'écran, pour que changer de dimension ne déplace rien sous l'œil. Ça n'a pas toujours été le cas : chacune avait sa propre technique de dimensionnement — le canvas 2D via max-width/height:auto sur ses attributs natifs, la 1D via aspect-ratio + padding, la 3D via aspect-ratio seule. Deux media queries mobiles aggravaient l'écart : à certaines largeurs, #board et #board-3d recevaient chacun un max-width explicite, sans équivalent pour #board-1d, qui restait à sa taille naturelle pendant que les deux autres rétrécissaient ensemble.

Un conteneur unique, #board-stage, porte désormais seul la taille (640 px de base, max-width:100%, aspect-ratio:1) et le centrage (margin:0 auto). Les trois vues ne sont plus que des enfants en position:absolute; inset:0, qui en remplissent chacune 100 % — bit pour bit la même boîte, quelle que soit celle qui est active. Les deux media queries ne redéclarent plus qu'une seule règle (#board-stage { max-width: … }) au lieu de deux tenues manuellement synchronisées.

Toute nouvelle vue rejoint #board-stage en position:absolute; inset:0, jamais avec sa propre largeur ou son propre centrage.

Caméra 3D : orbite, roulis, et contrôles au bouton

La caméra orbite autour du plateau selon trois paramètres : theta (azimut, horizontal), phi (élévation, borné à [0.15, π·0.49] pour ne jamais montrer le plateau par en-dessous) et radius (distance, borné à [12, 40]). Le drag couvre theta/phi, la molette couvre radius. Un quatrième paramètre, roll (rotation autour de l'axe de visée lui-même), n'avait initialement aucun contrôle — impossible à faire au doigt sans un second point de contact, et la caméra ne le supportait même pas.

Le roulis, une fois par frame, en valeur absolue. camera.lookAt() recalcule l'orientation avec un vecteur « haut » par défaut à chaque image — le roulis doit donc être réappliqué après coup (camera.rotateZ(roll)), et avec la valeur absolue de roll, pas un incrément : sinon il dérive de travers image après image, puisque lookAt() efface toute rotation accumulée au tour précédent.

camera.up ne reflète jamais un roulis appliqué par rotateZ() — c'est un simple indice donné à lookAt(), jamais réécrit ensuite. Le signal réel est l'orientation du quaternion (ce qui détermine ce qui apparaît à l'écran), obtenue en transformant l'axe Y local par ce quaternion. Un premier test avait vérifié camera.up par erreur, et ne prouvait donc rien.

Un seul panneau, une seule grille. Les angles de vue prédéfinis et les 8 boutons zoom/rotation (X, Y, Z, zoom avant/arrière) partagent un unique conteneur en grille CSS (3 colonnes depuis le retrait de ⬆, 4 auparavant), chaque bouton placé explicitement (grid-column/grid-row). Deux grilles séparées, alignées par des marges calculées à la main, ne peuvent pas produire un entrelacement précis entre colonnes de hauteurs différentes — le nombre de boutons dans chaque groupe a changé plusieurs fois en cours de route (14 → 12 → 11), et la disposition demandée entrelace des colonnes de tailles différentes. Chaque bouton coupe la rotation automatique au clic, comme le glisser ou la molette le font déjà implicitement.

Compter des occurrences de texte dans le code source (match(/poser\(/g).length) ne prouve pas le nombre de boutons réellement créés : un seul appel écrit dans une boucle en produit N à l'exécution. La preuve correcte exécute le bloc de construction avec un faux DOM et compte les éléments réellement produits.

Retirer un bouton devenu redondant libère un vrai emplacement, pas juste un espace vide. ⬆ (« vue de dessus ») imposait exactement les mêmes valeurs que le verrou « vue du dessus » actif par défaut — dès lors qu'un bouton fait toujours la même chose qu'un autre état déjà actif par défaut, il ne sert plus à rien. Retiré, la colonne qu'il occupait seul (avec 🔄, déjà retiré) est devenue entièrement vide : la grille elle-même repasse de 4 à 3 colonnes plutôt que de laisser un vide, et 🔒 migre dans l'emplacement ainsi libéré (colonne 3, sous ⚪) plutôt que de rester sur une colonne qui n'aurait plus eu de raison d'exister.

L'interpolation doit prendre le chemin le plus court. La caméra ne saute jamais à sa cible — elle s'y anime en douceur, theta += (tTheta - theta) * 0.1 à chaque image. À la frontière ±180°, cet écart brut pouvait valoir près de 2π même pour une rotation réellement minime : un clic Y calculait un écart de plusieurs centaines de degrés, la caméra partant presque à l'opposé avant de revenir — visuellement indiscernable d'un retour à l'état initial. Correctif : enrouler l'écart lui-même (wrapAngle(tTheta - theta)) dans (-π, π] avant de l'appliquer, pas seulement les angles en entrée. Le roulis (roll/tRoll) est sujet au même défaut à sa propre frontière, corrigé de la même façon.

Verrou « vue du dessus ». Un bouton dédié, actif par défaut, restreint la navigation à ce que la 2D peut aussi représenter : le zoom et la rotation Y (l'azimut) restent toujours libres, verrouillé ou non — le zoom parce qu'il ne fait jamais sortir de la vue du dessus, Y parce qu'il partage window.boardRotation avec la 2D (voir plus bas). Tout ce qui changerait l'élévation ou le roulis est bloqué. Le blocage tient à trois niveaux : CSS (opacity, pointer-events:none), une garde en tête de chaque action (if (topLocked) return;), et le geste de glisser lui-même — sa composante verticale est ignorée tant que verrouillé, sinon un simple doigt sur l'écran contournerait le blocage des boutons.

auto = false doit s'exécuter après la vérification du verrou, jamais avant — sinon un bouton bloqué produit quand même un effet de bord (couper l'auto-rotation) avant de renoncer à agir. Sans conséquence visible une fois pointer-events:none posé (le bouton devient physiquement inatteignable), mais pas un blocage complet en toute rigueur.

Les boutons côté (⚫/⚪) sont partagés avec la 2D, pas dupliqués. Comme Y↺/Y↻, ils ne touchent que window.boardRotation — jamais l'élévation ni le rayon, contrairement à leur ancienne version en préréglage complet. C'est ce qui les rend compatibles avec le verrou (ils restent toujours actifs) et avec la 2D (qui n'a aucune notion d'élévation). Colorés selon le skin réellement actif plutôt que noir/blanc figé — même calcul que les billes 3D et les bandeaux joueur, rafraîchi à chaque redessin du plateau principal et à chaque synchronisation 3D.

Chaque bouton X/Y/Z affiche désormais l'angle courant après le clic (un toast, comme rotateBoard() le fait déjà en 2D) — Y réutilise directement theta3DToBoardRotation(), donc affiche l'exact même nombre que la 2D pour le même état, pas une valeur de caméra qui aurait pu diverger visuellement.

Un pas qui ne divise pas 360° rend le retour exact à zéro presque impossible par incréments — mais ce n'est pas une fatalité. Le pas Y/Z valait initialement 11,5° : il aurait fallu 720 clics pour retomber exactement sur 0° (pgcd(115, 3600) = 5). Une première réponse avait consisté à rendre visible un raccourci existant (⚫/⚪ font un assignment direct, boardRotation = 0, pas un incrément) plutôt que de changer le pas, jugé délibéré. Olivier a ensuite choisi de changer malgré tout le pas à 10°, qui divise 360° exactement : 36 clics suffisent désormais. Les deux solutions ne s'excluaient pas — rendre visible un raccourci n'empêchait pas de reconsidérer le choix initial une fois le vrai coût (720 clics) rendu concret par le calcul.

Couleurs des billes 3D : suivre le skin actif, pas une couleur figée

Les billes 3D avaient un matériau noir/blanc fixe, sans rapport avec le skin choisi dans les paramètres. MARBLE_SKINS (une vingtaine de skins, chacun avec un dégradé de 3 à 5 teintes pour le noir et le blanc) et boardTheme.blackStops/whiteStops — remplis par applyMarbleSkin() à chaque changement de skin — sont la source unique déjà utilisée par drawMarble() en 2D. La 3D lit cette même source plutôt que de redéfinir des couleurs à part, qui auraient fini par diverger au premier nouveau skin ajouté.

Quelle teinte du dégradé. La 2D simule le relief avec un dégradé radial synthétique — clair près d'un reflet dessiné, sombre dans une ombre dessinée. La 3D a une vraie lumière qui recrée ce relief par elle-même : les deux extrémités du dégradé 2D sont des artifices de ce rendu synthétique, pas la couleur « propre » du matériau. La teinte du milieu du dégradé sert de couleur de base au matériau MeshStandardMaterial, laissant l'éclairage réel de la scène produire highlight et ombre.

Rafraîchie à chaque synchro, pas seulement à la création. La scène 3D est construite une seule fois par session (les visites suivantes appellent seulement .sync(), pas une reconstruction complète — voir plus haut). Sans réappliquer les couleurs à chaque sync(), un skin changé dans les Paramètres pendant que la 3D n'est pas affichée resterait figé sur l'ancien skin au prochain retour sur cette vue.

Restent hors de portée d'une simple couleur de base, et donc 2D uniquement : les veines dorées du skin Kintsugi, le nacrage du blanc.

Skin du PLATEAU : cinq vrais thèmes, plus une logique binaire

setBoardTheme() définit six noms (dark, forest, ocean, marble, gold, wood). Jusqu'à la v1.39, la fonction de rendu 2D ne testait que boardTheme.wood : les cinq autres thèmes produisaient un rendu strictement identique, malgré des bg1/bg2 définis mais jamais lus nulle part — des propriétés mortes.

Avant de synchroniser une vue sur une autre, lire le vrai code de rendu — pas seulement les propriétés que la fonction de réglage définit. Une propriété assignée n'est pas une preuve qu'elle est utilisée quelque part.

Depuis la v1.40, BOARD_SURFACE_THEMES (une seule palette par thème : gutterOuter, gutGrad, trim, surf) alimente réellement le feutre, la gouttière et le contour en 2D — et syncBoardTheme3D() relit cette même source (window.BOARD_SURFACE_THEMES, exposée depuis le bloc <script> du rendu 2D) pour la couleur des trous et le fond de scène en 3D. Le bois reste un cas à part dans les deux vues — un traitement plus riche (grain, rivières de résine) qui ne se résume pas à deux couleurs, non concerné par cette palette partagée.

Le chantier a été retrouvé à moitié fait : BOARD_SURFACE_THEMES et boardTheme.theme existaient déjà, mais seul un quart du câblage 2D était branché, et la 3D n'avait pas été touchée du tout. Avant d'implémenter une fonctionnalité qui semble manquante, vérifier qu'elle n'a pas déjà été commencée ailleurs dans le fichier — ré-implémenter en double coûte plus cher que compléter.

Un piège d'ordre entre fonctions sœurs

start3DScene() construit son état (caméra, matériaux, boutons) puis appelle sync() une première fois pour peupler les billes. Une fonction ajoutée pour être appelée depuis sync() — mais dont la déclaration let/const se trouve après cet appel dans le même bloc — provoque une erreur de zone morte temporelle au tout premier chargement : sync() est elle-même hoistée (les déclarations function le sont toujours), donc l'appel ne plante pas immédiatement en apparence, mais tout ce qu'elle référence via une variable pas encore initialisée fait planter l'exécution en cascade, avant même que la caméra ou la boucle de rendu ne démarrent. Le plateau reste noir, sans message d'erreur visible ailleurs qu'en console.

Toute variable qu'une fonction sœur doit lire doit être déclarée avant le premier appel de cette fonction sœur — pas seulement avant sa propre définition. Un test qui exécute la fonction isolément avec ses propres variables factices ne peut pas voir ce genre de problème d'ordre : il faut exécuter la construction complète, dans l'ordre réel du fichier, pour le révéler.

Son et vibration : granulaires par vue

Chaque vue a son propre profil sonore, réglable indépendamment dans les Paramètres. Par défaut : la 1D est entièrement silencieuse et sans vibration (une vue texte pensée pour la lecture concentrée), la 2D garde le son mais pas de vibration (le jeu « normal »), la 3D active les deux (l'expérience la plus immersive).

Le gating ne s'applique qu'aux quatre micro-interactions du plateau — déplacement, poussée, éjection, sélection. Les événements globaux du jeu (victoire, badge débloqué, alerte de temps) restent sous le seul contrôle de l'interrupteur général « Son », inchangé : ils ne sont pas propres à une vue, un badge débloqué en 1D n'a pas de raison d'être silencieux juste parce que la 1D coupe le son des coups.

Le son garde l'interrupteur général comme coupe-circuit par-dessus le réglage par vue : même une vue autorisée reste muette si le son est coupé partout. La vibration n'avait pas d'équivalent global avant ce réglage — les trois cases par vue en sont le seul contrôle.

Bandeaux joueur : la même information, adaptée à chaque vue

« Joueur 1 » / « Joueur 2 » (unifiant les anciennes formulations Vous/Adversaire/Joueur noir/Bot Noir…) s'accompagnent d'un repère visuel qui change de forme selon la vue, sans changer de sens :

  • En 2D et 3D, une pastille ronde reprend la teinte réelle du skin actif — le même calcul que les billes 3D (teinte du milieu du dégradé).
  • En 1D, une pastille ronde détonnerait dans une vue de texte monochrome sans rien de rond ni de coloré par ailleurs. Elle est remplacée par le symbole réel du plateau : O pour Joueur 1, @ pour Joueur 2 — fixe, indépendant de la couleur réellement jouée, puisque « Joueur 1 » désigne un siège (la barre du bas), pas une couleur. La couleur du skin reste appliquée au caractère.

La taille du symbole 1D est mesurée, pas recopiée. getComputedStyle lit la taille réellement rendue de #board-1d-text au moment de l'affichage, plutôt qu'une valeur dupliquée en dur qui aurait divergé au prochain réglage de la taille du plateau (déjà ajustée deux fois dans la même session). Les deux restent identiques automatiquement, sans code à resynchroniser manuellement à chaque changement.

Texture bois + résine époxy en 3D : une vraie surface, et un vrai relief

La surface de jeu en 3D (top, un hexagone extrudé) est un mesh distinct des trous individuels et du fond de scène — découvert en cherchant où appliquer un rendu riche : holeMat/scene.background ne couvrent que les creux et l'arrière-plan, jamais la « table » elle-même.

Le grain du bois est une texture procédurale (canvas 2D → THREE.CanvasTexture, zéro image externe) — 640×640, centre (320,320), exactement les coordonnées du canvas 2D de référence (hexCoord utilise cx=cy=320), pour occuper les mêmes proportions du plateau des deux côtés. Construite une seule fois et mise en cache (getWoodTexture() retourne toujours la même référence) : sync() tourne à chaque coup, redessiner ce canvas à chaque fois serait un gâchis pour un résultat identique.

Une texture plate n'est pas un relief, même si le motif qu'elle dessine en a l'air. Première tentative : les rivières d'époxy étaient peintes sur la texture du grain, avec le même tracé sinusoïdal que la 2D — visuellement plausible sur une capture d'écran, mais en 3D interactive, incliner la caméra ne révèle jamais aucune épaisseur, puisqu'il n'y en a pas. Signalé à raison : « il faut des sinusoïdes d'époxy en 3D ».

Les rivières d'époxy sont désormais une vraie géométrie (getRiverMeshes()) : un ruban ExtrudeGeometry par rivière, suivant le même tracé sinusoïdal exact que la 2D (mêmes y/amp/w/phase, même formule riverY), converti de l'espace pixel 2D vers l'espace monde 3D par un simple facteur d'échelle (rayon réel du plateau ÷ demi-largeur du canvas 2D de référence) — pas une nouvelle proportion inventée. Matériau MeshPhysicalMaterial (transmission, clearcoat) pour un effet vitreux, légèrement en saillie sur le bois. Construites une seule fois et mises en cache comme la texture de grain, puis simplement ajoutées ou retirées du groupe de la scène selon que le thème bois est actif — jamais reconstruites, jamais dupliquées sur des appels répétés.

Jour/Nuit : un second axe sur les thèmes de plateau, un theme à la fois

resolveThemeColors(nom, estNuit) résout les couleurs réelles d'un thème, qu'il soit plat (une seule teinte, les 5 thèmes d'origine) ou jour/nuit ({day:{...}, night:{...}}, Volcanique — le premier). Un seul point de lecture, utilisé à l'identique en 2D et en 3D, retrocompatible par construction : un thème plat ignore silencieusement le drapeau night, jamais d'erreur, jamais de branche spéciale à ajouter ailleurs pour les thèmes qui n'ont pas encore de variante.

Vérifier une couleur choisie à l'œil contre la vraie photo de référence, pas contre son propre souvenir de la photo. Première tentative sur Volcanique : couleurs plausibles mais inventées, trop chaudes et brunes. Corrigé en échantillonnant programmatiquement les pixels de la photo fournie (filtrage par teinte pour isoler la roche du hors-sujet, puis les veines de lave par plage de couleur) — la vraie roche est un gris-noir neutre, les veines beaucoup plus ternes le jour qu'intenses la nuit. Choisir « à l'œil » depuis la description d'une image reste une supposition, pas une mesure.

Textures procédurales pour les 6 thèmes de plateau — 2D et 3D, deux contraintes différentes

Jusqu'à la v1.43, seul le bois avait une vraie texture (grain + rivières) ; les 5 autres thèmes (et Volcanique à son arrivée) n'étaient que des dégradés unis. Chaque thème a désormais sa propre texture, choisie pour sa matière plutôt qu'un même bruit générique partout : verre lisse à reflets courbes pour Obsidian, ondulations et taches de lumière pour Océan, veines turbulentes pour Marbre, rayures fines et reflet diagonal pour Or, taches organiques pour Forêt, fissures en marche aléatoire ramifiée pour Volcanique (couleur de lave différente jour/nuit, pas seulement plus sombre).

La 3D et la 2D partagent le même dessin, mais pas la même contrainte de performance. En 3D, une texture est construite une fois et posée sur un mesh qui ne change pas — la mettre en cache est confortable, pas vital. En 2D, drawBoard() redessine le canvas entier à chaque coup et à chaque survol : peindre une texture avec Math.random() à chaque appel aurait été lent, et surtout instable visuellement — le motif aurait changé de forme à chaque redessin, puisque Math.random() ne redonne jamais la même valeur. La solution : peindre chaque texture une seule fois sur un canvas hors-écran mis en cache par (thème, jour/nuit), puis la coller (drawImage) à chaque redessin — aussi rapide qu'un dégradé, motif parfaitement stable. Vérifié explicitement par un test qui appelle la fonction 20 fois de suite et confirme zéro reconstruction.

Un mécanisme de mise en cache qui fonctionne pour un cas d'usage (la 3D, qui redessine rarement) ne se généralise pas automatiquement à un autre (la 2D, qui redessine sans cesse) sans vérifier la fréquence réelle d'appel. La performance et la stabilité visuelle sont deux raisons indépendantes d'avoir mis en cache ici — l'une ou l'autre aurait suffi à justifier le choix.

La lueur (emissive) reste réservée à Volcanique pour l'instant (jour discret, nuit intense) — même logique progressive que jour/nuit : un thème à la fois, les autres pourront suivre plus tard.

Fissures de lave (Volcanique) : le même chemin que les rivières d'époxy, du peint au relief

Les fissures ont d'abord existé comme la première version des rivières d'époxy avant elles — peintes à plat sur la texture du plateau en 3D, pas une géométrie. Corrigé en suivant exactement le même chemin : getLavaCrackMeshes(night) construit un tube (THREE.TubeGeometry) par fissure, au matériau émissif, converti vers l'espace monde 3D par le même facteur d'échelle que les rivières. La texture 3D du plateau volcanique ne porte plus que la roche et les pores — les fissures ne sont plus peintes dessus, elles sont en relief, mises en cache par jour/nuit, ajoutées ou retirées du groupe de la scène selon le thème actif.

Le tracé lui-même — une marche aléatoire ramifiée — est désormais généré une seule fois et partagé entre la 2D (qui continue de le peindre à plat, faute de géométrie) et la 3D (qui en fait des tubes). window.genererFissures(nb) renvoie des données pures (un tableau de points), pas un dessin — un seul générateur, deux consommateurs, exactement le même principe que y/amp/w/phase déjà partagés entre les rivières 2D et 3D.

Centraliser un calcul dupliqué révèle parfois un bug qu'aucune des deux copies ne montrait seule. L'ancien code peignait le halo nocturne (large, doux) et le trait fin par-dessus (le cœur de la fissure) avec deux appels indépendants à la même fonction de dessin — chacun relançant son propre tirage aléatoire. Résultat : le halo et le trait ne suivaient PAS le même tracé, la lueur ne « collait » pas visuellement à la fissure, sans qu'aucun test unitaire sur l'une ou l'autre couche ne le remarque puisque chacune était individuellement cohérente avec elle-même. En générant le tracé une seule fois et en le redessinant deux fois avec des styles différents, le halo entoure désormais réellement le trait — un bug purement visuel, invisible dans le code tant qu'on ne le centralise pas.

Moteur générique de matériaux par COUCHES — la base du créateur de plateau à venir

Demandé par Olivier : un éditeur visuel (curseurs + aperçu live 2D/3D) pour créer ses propres thèmes de plateau. Un éditeur a besoin de quelque chose de réglable derrière lui — cette étape pose cette base, sans aucun changement visuel : les 6 thèmes existants deviennent des données plutôt que du code figé, vérifiés par rendu réel (capture d'écran dans un navigateur headless) pour confirmer qu'ils restent identiques à l'œil.

Avant, chaque thème était un bloc if/else codé en dur, dupliqué à l'identique entre le peintre 2D et le peintre 3D — deux copies de six recettes à maintenir en double à chaque changement. window.LAYER_TYPES catalogue désormais 11 motifs réutilisables (dégradé, courbes larges, poussière, vagues, caustiques, veines turbulentes, rayures fines, reflet diagonal, taches organiques, pores, fissures), chacun une fonction pure : contexte canvas + paramètres → dessin. window.THEME_RECIPES associe chaque thème à une liste ordonnée de couches avec leurs paramètres — les couleurs restent résolues dynamiquement via resolveThemeColors, seule la structure (quels motifs, dans quel ordre, avec quelles plages de valeurs) devient une recette. window.peindreCouches exécute une recette sur un contexte donné. Les deux peintres (2D et 3D) appellent désormais ce même moteur.

Volcanique garde sa particularité (fissures peintes en 2D, vraie géométrie en 3D depuis la section précédente) via une simple option de la recette ({ sansFissures: true }), pas un chemin de code séparé — la différence 2D/3D est un paramètre, pas une duplication.

Un thème personnalisé (à venir) sera juste une recette de plus, gérée exactement comme les 6 autres — pas un code spécial à part. C'est le test de tout moteur généralisé fait correctement : la prochaine extension ne devrait pas nécessiter de nouvelle branche, seulement une nouvelle entrée de données.

Mode commentateur : pur observateur, jamais un accès direct à l'état du jeu

Le fichier expose déjà un système d'événements publics documenté (abalassembly:movePlayed, gameOver, positionChanged), pensé pour des outils externes. Le panneau de stats des deux joueurs est construit en écoutant uniquement ces événements — comme le ferait un outil tiers — sans toucher une seule ligne du moteur de jeu. C'est la preuve que ce système d'événements, déjà là, sert à quelque chose au-delà de sa documentation.

capturedByBlack compte en réalité les billes blanches éjectées par les noirs — l'inverse de ce que le nom suggère (nommage historique du fichier). Toute nouvelle fonctionnalité qui lit ces compteurs doit vérifier ce sens avant de les afficher, pas se fier à l'intuition du nom.

Mode projecteur : plusieurs fenêtres, un seul état, synchronisées sans serveur

?projector=1d|2d|3d dans l'URL bascule la page en mode spectateur plein écran : #board-stage est réparenté (déplacé, jamais cloné — le même élément, avec tous ses écouteurs et références JS intacts) dans un conteneur dédié, et le reste de l'interface disparaît en CSS (body.projector-mode > *:not(#projector-root) { display:none }).

La synchronisation entre fenêtres passe par BroadcastChannel, une API native du navigateur : aucune dépendance, aucun serveur, seule contrainte réelle — les fenêtres doivent partager la même origine (trivialement vrai ici, c'est le même fichier ouvert plusieurs fois). La fenêtre principale émet l'état à chaque drawBoard() du plateau principal ; chaque fenêtre projecteur reçoit et redessine.

let board/let boardTheme sont des déclarations lexicales, pas des var : invisibles via window.board/window.boardTheme depuis un autre bloc <script>. Le code de réception du mode projecteur vit délibérément dans le même bloc que ces déclarations, pour muter board/selected/currentTurn/boardTheme par identifiant nu en toute fiabilité — window.boardRotation, lui, est une propriété explicite de window (voir plus haut) et reste lisible de partout.

Le verrou d'interaction canonique ne suffisait pas partout. canInteractWithBoard() est déjà utilisé par la 1D et la 3D, mais le clic 2D principal et le glisser-déposer souris avaient chacun leur propre copie des mêmes vérifications, sans jamais appeler la fonction partagée — une vraie duplication trouvée en vérifiant, pas supposée. Le mode projecteur ajoute son verrou aux trois endroits : la fonction canonique, et les deux points qui la contournaient.

Un « seul point d'entrée » documenté (handleClick) ne garantit pas un seul point de garde : chaque appelant peut avoir sa propre copie du verrou avant d'y arriver. Vérifier chaque appelant, pas seulement la fonction canonique, avant de supposer qu'un nouveau verrou s'applique partout.

Ajouter une vue

  1. Rendre à partir de board, jamais d'un état recopié.
  2. Faire aboutir chaque interaction à handleClick(r, c).
  3. Répliquer les trois verrous d'interaction (canInteractWithBoard).
  4. Se brancher sur drawBoard() pour le rafraîchissement, via un refreshXIfActive sur le modèle existant.
  5. Ne réimplémenter aucune règle. S'il faut en écrire une, c'est que le moteur doit l'exposer — computeMoveHints et computePlayableCells en sont l'illustration pour les indices de coups.
  6. Rejoindre #board-stage en position:absolute; inset:0 — jamais une taille ou un centrage indépendants.

10 nouveaux thèmes de matériaux (v1.48) : la mise à l'épreuve du moteur de couches

Métal, Bronze, Cuivre, Ardoise, Granite, Verre, Béton, Corail, Glace, Carbone — le premier vrai test à grande échelle du moteur générique posé en v1.46. Chaque thème n'ajoute que deux choses : une palette dans BOARD_SURFACE_THEMES (gouttière, dégradé, contour, surface) et une recette dans THEME_RECIPES, assemblée à partir des 11 motifs déjà catalogués — grain directionnel (rayures_fines) pour le métal brossé, patine organique (taches_organiques) pour le bronze et le cuivre, mouchetis sel-et-poivre (deux couches poussiere superposées, une claire une sombre) pour le granite, caustiques et reflet vif pour le verre et la glace. Aucun nouveau type de couche n'a été nécessaire.

La 3D hérite des 10 thèmes sans qu'une seule ligne de son code n'ait été touchée — peindreTextureTheme (3D, autre bloc <script>) et peindreTextureTheme2D lisent le même window.THEME_RECIPES. C'est la validation la plus concrète possible du principe posé en v1.46 : « un thème personnalisé sera juste une recette de plus, gérée exactement comme les autres » — dix fois de suite, sans exception.

Vérifié par comparaison de pixels réels, pas seulement à l'œil : les 10 nouveaux thèmes sont tous mesurablement distincts entre eux et des 6 thèmes existants. Deux recettes qui semblent différentes en lisant le code (couleurs différentes, motifs différents) peuvent malgré tout produire un rendu visuellement proche une fois les calques superposés — seule une vraie mesure sur les pixels le confirme.

Créateur de plateau (v1.49) : un thème perso n'est jamais du nouveau code

Demandé depuis la v1.46, posé comme infrastructure sans être construit — c'est désormais fait (Profil → Personnaliser le plateau → « 🧪 Créateur de plateau »). Le principe annoncé à l'époque tient : « un thème personnalisé sera juste une recette de plus, gérée exactement comme les autres ».

Deux curseurs génériques post-traitent la liste de couches renvoyée par n'importe laquelle des 16 recettes existantes — creatorScaleLayers(couches, densite, intensite) multiplie le nombre d'éléments (n) de chaque couche par densite, et tous les canaux de transparence (alpha, alphaMin, alphaMax, alphaPic) par intensite, bornés à [0,1] et à un minimum d'un élément. Deux opérations génériques, aucun code spécifique par thème : elles marchent identiquement sur les 16 recettes intégrées.

creatorRegisterPerso() enregistre le résultat (base + couleurs choisies) directement dans BOARD_SURFACE_THEMES.perso et THEME_RECIPES.perso — les mêmes registres globaux que les 16 autres thèmes. À partir de là, « perso » est un thème comme n'importe quel autre pour tout le reste du code : la 3D en hérite automatiquement, sans une seule ligne de son code touchée, la preuve la plus concrète que le moteur de couches de la v1.46 tient sa promesse.

Persistance en données pures, jamais en code sérialisé : localStorage ne stocke que {base, c1, c2, gutter, densite, intensite} (du JSON texte), jamais une fonction. Au chargement, une vraie fonction recette est reconstruite à partir de ces données — cohérent avec le principe « recette = données » posé en v1.46, pas une exception pour le cas personnalisé.

L'aperçu live n'existe qu'en 2D. Un aperçu 3D en direct dans le panneau demanderait sa propre mini-scène Three.js dédiée — un chantier à part, pas fait ici. La 3D fonctionne correctement une fois le thème appliqué au vrai plateau (héritage automatique déjà prouvé), simplement sans aperçu avant de cliquer « Appliquer ».

12 thèmes supplémentaires et modèle physique par matière (v1.50)

18 nouveaux thèmes de plateau au total (Onyx, Jade, Stardust, Terracotta, Banquise, Nacre, Cuivre Patiné, Désert, Abysses, Lave, Marbre Blanc, Pastel...), issus de deux séries de propositions externes fact-checkées avant intégration. Sur 68 couches soumises au total, 41 plantaient ou étaient silencieusement cassées à l'exécution réelle contre le vrai window.LAYER_TYPES — pas des détails isolés mais une erreur systématique : paramètres inventés (alpha, epaisseur, turbulence, graine, couleur1/couleur2) qui ne correspondent à aucune fonction réelle du moteur. Reconstruites entièrement avec les vrais paramètres, en gardant les intentions colorimétriques d'origine.

Attention portée aux doublons conceptuels : « Lave » et « Marbre blanc » auraient pu se confondre visuellement avec « Volcanique » et « Marbre » déjà existants — vérifié par comparaison de pixels réels que ce n'est pas le cas, pas seulement supposé du fait des noms différents.

roughness/metalness par thème

Jusqu'à la v1.49, un seul couple roughness/metalness fixe (0.45/0.35) s'appliquait à tous les matériaux en 3D — le verre et la pierre avaient donc la même réponse physique à la lumière que le métal, seule la couleur changeait. THEME_PHYSIQUE donne à chacun des 28 thèmes (+ le bois) sa propre paire de valeurs, cohérente avec sa nature : le verre est lisse et non-métallique (roughness: 0.05), le métal brossé lisse et métallique (roughness: 0.35, metalness: 0.85), la pierre mate et non-métallique (roughness: 0.65-0.85). Combiné aux normal maps procédurales (v1.49), la lumière 3D réagit désormais vraiment différemment selon la matière.

Aperçu 3D en direct dans le Créateur de plateau

Le Créateur de plateau (v1.49) n'avait qu'un aperçu 2D. Un aperçu 3D s'ajoute maintenant à côté, sur un simple disque texturé — pas le contour hexagonal complet, volontairement, pour rester léger dans un panneau de 200×200px. Il réutilise le même canvas peint que l'aperçu 2D comme texture diffuse et comme source pour la normal map procédurale : jamais repeint une deuxième fois avec un code différent.

Bug de conception trouvé et corrigé pendant le développement : les fonctions de l'aperçu 3D (creatorInit3DPreview, THEME_PHYSIQUE, genererNormalMap) avaient été ajoutées à l'intérieur de start3DScene() — donc n'existaient qu'après que la vraie vue 3D ait été ouverte au moins une fois, ce qui aurait rendu l'aperçu du Créateur inutilisable tant qu'on n'était pas passé par ailleurs. Remontées au niveau supérieur du bloc <script>. Vérifié explicitement en navigateur que les fonctions existent dès le chargement de la page, sans dépendre de l'ordre d'ouverture des vues.

Traînée et explosion de lave à l'éjection (v1.51)

Réutilise ejectAnim/ejectAnimStep, déjà en place depuis longtemps pour l'animation de vol d'une bille éjectée — aucun nouveau système d'animation. Trois petites fonctions ajoutées (_ajouterParticuleLave, _dessinerTraineeLave, _dessinerExplosionLave), strictement conditionnées à boardTheme.theme === 'volcanique' : aucun autre thème n'est affecté, vérifié explicitement (_volcaniqueActif() testé sur plusieurs thèmes).

Budget mémoire fixe sur la traînée (40 particules maximum, la plus ancienne est retirée quand une nouvelle apparaît) — jamais de fuite même sur une trajectoire longue depuis le centre du plateau.

La première version se confondait avec le thème lui-même. Le halo orange de la traînée utilisait la même teinte et la même largeur que les fissures déjà peintes par la recette Volcanique — visuellement invisible en mouvement, noyé dans la texture statique. Trouvé par une vraie capture d'écran d'une éjection en vol, pas supposé correct après lecture du code. Corrigé en ajoutant un cœur vif et étroit par-dessus le halo, seule combinaison qui reste lisible pendant le déplacement.

Origine : une proposition externe non intégrable, mais l'idée retenue

Un fichier « plateau volcanique jouable à 100% » avait été proposé, prétendument branché sur une vraie photo de plateau. Vérifié avant d'intégrer quoi que ce soit : React + Tailwind compilé, zéro <canvas>, dépendance CDN externe (Google Fonts) — et la photo annoncée dans le message n'existait nulle part dans le fichier livré, une affirmation fausse sur son propre contenu. Les règles et la mécanique de jeu existaient déjà dans Abalassembly et fonctionnaient correctement ; seuls la traînée et l'éclat à l'impact manquaient réellement. Portés ici en vanilla JS sur le vrai moteur, pas copiés depuis le fichier proposé.

Clone this wiki locally