Sur cette page

← Toute la documentation

Skins

Écrire un skin : defineSkin, référence du playfield, dessin natif, configuration du joueur.

Un skin est du code TypeScript. À chaque lancement de map, le jeu exécute le skin.ts du skin choisi ; ce script place le playfield natif (position, tailles, sens de défilement, images et teintes de chaque colonne) et construit le HUD de la surcouche WebView (barre de progression, combo, précision…) avec l'API hud.* des mods. Pendant la partie, ses gestionnaires d'événements changent le playfield (playfield.update, lanes.set). Le rendu natif dessine le playfield et, avec la permission stage, les éléments natifs du skin (voir Dessin natif) ; le HUD est entièrement dans la surcouche.

Où sont les skins

  • Skins du jeu : de simples dossiers de skins/ livrés à côté de prism.exe (en développement, le dossier skins/ du dépôt, trouvé en remontant depuis l'exécutable jusqu'au dossier de Cargo.lock), chargés depuis le disque exactement comme les skins du joueur : aucun skin n'est incorporé au binaire, chacun se lit, se modifie, s'exporte et se supprime comme les autres (pas de « Copier pour modifier »). Le moteur n'en connaît aucun. Les skins du jeu :
    • default (« Prism Default », réglage par défaut) : skins/default/, le skin osu! du joueur « PRISM // arrow (cap ends) » (par april, assets/skins/default.osk) converti depuis son skin.ini pour les dispositions 4K à 7K du jeu. Colonnes de 62 px (4K ; 58, 54 et 50 px en 5K, 6K et 7K) sur un écran virtuel de 480 (laneWidth 0,129 en 4K, sans écart), colonnes #07090f96, bord clair d'un pixel (les images StageLeft et StageRight), flèches sombres (left, down, up, right, plus center, upleft et upright des dispositions impaires ou en 6K) et leurs touches allumées à l'appui, corps de hold et embout à capuchon. HitPosition 430 place le bas des notes sur la ligne : les notes sont centrées une demi-colonne plus haut (receptorY = 430/480 − laneWidth/2) et les images de touche portent des lignes transparentes au-dessus de la flèche pour que leur centre soit celui de la flèche. Combo (ComboPosition 240) et dernier jugement (ScorePosition 188) sont centrés. Les images @2x de l'archive sont reprises telles quelles (touches de 4K, center de 5K et upleft/upright de 6K ramenés à 198 px de large, les autres dispositions réutilisent les flèches de 4K) ; l'éclairage est repris (StageLight : stageLight teinté #aae4ff (ColourLight1) ; LightingN : hitLight, 8 images fx/explosion-{n}.png, jouée à chaque frappe ; LightingL : holdLight, 6 images fx/holdlight-{n}.png, à LightFramePerSecond 60, sur la position de frappe LightPosition 430, une demi-colonne sous la ligne des récepteurs). Ne sont pas dans le jeu : les images de jugement, de score et de combo (le jugement est un mod), les lignes de colonne (5 % de blanc), les sons, le curseur et l'animation du corps de hold (une seule des 8 images est gardée). L'archive d'origine reste hors du jeu. La police du jeu est assets/font.ttf, hors du skin ; le dossier reste sous 900 Kio (test) ;
    • default-circle (« Default Circle », par tekkito2) : skins/default-circle/, la disposition 4K du skin osu! « tekkito2 ft jb the voice tu perfume a chanel » portée depuis son skin.ini : colonnes de 72 px sur un écran virtuel de 480 (laneWidth 0,15, sans écart), ligne de jugement à 440 (receptorY 0,9167, judgementLine), colonnes noires à 200/255, notes rondes (cercle extérieur aux colonnes 0 et 3, intérieur aux autres), combo et jugement centrés sous le playfield (ComboPosition 130, ScorePosition 150). Le récepteur, la « stage hint » et les images d'éclairage (lightingN, lightingL, lighting, mania-stage-light) du skin osu! sont des images transparentes : rien ne s'affiche à ces endroits, comme dans osu! (aucune image stageLight). Les notes sont centrées sur la ligne de jugement (milieu de la note, noteOffsetY 0). Crédits : le corps de hold et l'embout de hold ne viennent pas du skin de tekkito2 (son embout est transparent, son corps une image grise de 138 × 40 000 px) mais du skin default (« PRISM // arrow (cap ends) » par april) : hold_body.png est une copie de notes/body.png (256 × 32 px) et hold_cap.png une copie de notes/tail-cap.png (256 × 256 px, 3,7 Kio). Ce sont des copies autonomes, le skin ne référence jamais le dossier d'un autre skin. Le corps fait la largeur des notes (holdWidth 0,15), l'embout la largeur du corps (holdEndSize 0,15 × 0,15, sans holdEndOffset), teintes blanches. Les images lourdes de l'archive (22 Mo), les .psd, les sons, le curseur et les scores n'y sont pas : seuls 6 fichiers PNG et le script pèsent environ 30 Kio. L'archive d'origine est assets/skins/circle-default.osk (hors du jeu).
  • Skins du joueur : un dossier par skin dans <données>\skins\<id>\ (%LOCALAPPDATA%\Prism\PrismNG\data\skins), ou dans le dossier donné par PRISM_SKINS_DIR (qui ne remplace que cette racine des données du joueur ; les skins du jeu restent lus dans skins\ à côté de prism.exe). La liste prend d'abord les skins du jeu, puis ceux du joueur ; si deux dossiers portent le même identifiant, le premier gagne et un message le signale. Le nom du dossier est l'identifiant du skin sur cette machine (1 à 64 caractères valides sous Windows, ni caché). Sans le skin choisi, le premier skin disponible est utilisé ; sans aucun skin, le moteur joue ses propres valeurs par défaut (rectangles blancs, aucune image) avec un seul avis visible « Aucun skin trouvé : placez un dossier de skin dans skins/ ». Un dossier sans skin.ts n'est pas un skin (signalé sur la page Skins).

Un paquet contient skin.ts (script unique, sans import, 1 Mio au plus), des images .png et des polices .ttf/.otf. Les fichiers sont toujours nommés par des chemins relatifs au dossier (notes/rouge.png) : pas de .., de chemin absolu, de nom réservé Windows ; un lien symbolique ou une jonction qui sortirait du dossier est refusé.

Le script

export default defineSkin({
  id: "neon",            // règles d'un id de mod
  name: "Neon",
  version: "1.0.0",
  apiVersion: 2,         // version de l'API des mods du jeu
  author: "Moi",
  uses: { "pvng.judgement-display": "^1" },
  setup(play: PlayContext) {
    playfield.set({ laneWidth: 0.07, noteColor: "#ff4080", scroll: "up" });
    lanes.set({ column: 0, lane: { note: "notes/gauche.png" } });
    // HUD : hud.text(...), hud.group(...), voir docs/modding.md
  },
});

defineSkin accepte aussi images (images chargées pour la partie, voir plus bas) et permissions (["stage"] pour le dessin natif).

setup(play) reçoit le contexte du lancement (PlayContext ; ctx est déjà le nom global de ctx.on(...)) : mode, layout (disposition du catalogue des modes), layoutSkin (son apparence, par exemple 4k), columns, screenWidth/screenHeight (pixels), song et judgements (paliers de la partie). Le script est rechargé à neuf à chaque lancement : rien ne s'accumule d'une partie à l'autre.

  • playfield.set(spec) fusionne spec (un PlayfieldSpec) dans la description du playfield : les champs donnés remplacent ceux des appels précédents, les autres restent.
  • lanes.set({ column, lane }) fusionne un LaneSpec dans la colonne column (à partir de 0, 32 colonnes au plus).
  • Un champ jamais donné garde l'apparence par défaut du moteur de la disposition lancée : un script sans playfield.set dessine le playfield historique.
  • Le HUD n'est plus dessiné par le skin : chaque élément est fourni par un mod (dossier de mods/), et le skin le place et l'habille avec ctx.element("<paquet>/<élément>").configure({ … }), après l'avoir déclaré dans uses (optionnel) et testé avec ctx.has. Les valeurs qui changent à chaque jugement ou image (combo, précision, progression…) sont des liaisons (bind, fill, showWhen, animate, element) que les mods posent sur leurs nœuds et que la surcouche résout elle-même : voir modding. La section HUD de skins/default/skin.ts (et celle de skins/default-circle/skin.ts) sert d'exemple complet : elle ne crée aucun nœud hud.*.
  • Les éléments des mods du jeu : pvng.accuracy/accuracy, pvng.combo/combo, pvng.counters/timer, …/remaining, …/hits, …/misses, pvng.progress-bar/progress, pvng.fps/fps, pvng.pause-status/status (options dans modding). Tous prennent x, y, anchor, size et visible (sauf la barre : width, height, radius…), plus leurs couleurs. Le skin par défaut livre hits et misses avec visible: false : la configuration du skin du joueur peut les afficher. Les positions et tailles qui dépendent de l'écran se calculent avec play.screenWidth et play.screenHeight, comme le fait le skin par défaut.
  • Le dernier jugement et les compteurs par palier viennent du mod pvng.judgement-display, dans les couleurs des paliers de la partie. Un skin le déclare dans uses ("^1", ou { version: "^1", feature: "…" } pour nommer ce qui manque sans lui, comme le skin par défaut) puis le place et l'habille dans son setup : ctx.element("pvng.judgement-display/judgement").configure({ x, y, anchor, size, animation, colors }) et …/counts (options dans modding). Sans le mod, ctx.has vaut false, un message l'indique sur la page Mods et le skin se passe de cet affichage (voir dépendances).
  • La hit bar est un autre mod, pvng.hit-bar. Ajoutez "pvng.hit-bar": "^1" à uses, puis placez son élément si ctx.has("pvng.hit-bar") : ctx.element("pvng.hit-bar/bar").configure({ x: 0.5, y: 0.427, anchor: "top", width: 0.42, height: 0.055, length: 50, visible: true }). Elle utilise les derniers offsets natifs non-Miss et des primitives HUD ordinaires. Sa présence ne dépend pas de pvng.judgement-display. Les skins peuvent donc choisir séparément le dernier jugement, les compteurs et la visualisation des impacts.

Les types (PlayfieldSpec, LaneSpec, LaneUpdate, PlayfieldAnchor, ScrollDirection, SpriteSize) sont définis en Rust dans crates/skin (#[derive(TsSchema)]) et générés dans le SDK TypeScript des mods (mods/sdk/modding.d.ts).

Référence du playfield

Positions en fractions de l'écran (x de sa largeur, y de sa hauteur, depuis le coin haut-gauche) ; tailles en fractions de la hauteur de l'écran : le playfield garde ses proportions à toute résolution.

Champ Bornes Défaut Rôle
x, y 0 à 1 0,5 ; 0,5 position du point d'ancrage de la boîte des colonnes
anchor topLeft … bottomRight center point de la boîte placé en x, y
rotation −3600 à 3600 0 rotation du playfield entier autour du centre de la boîte des colonnes, en degrés, sens horaire
zoom 0 à 4 1 échelle du playfield entier autour du même centre
laneWidth 0,005 à 0,5 0,085 largeur d'une colonne
width 0,005 à 2 — largeur totale ; fixe la largeur de colonne quel que soit leur nombre (prioritaire sur laneWidth)
laneGap 0 à 0,1 0,003 espace entre deux colonnes : elles sont distantes de laneWidth + laneGap, l'écart ne rétrécit jamais une colonne
laneHeight 0 à 1 0,9 hauteur de la boîte des colonnes
receptorY 0 à 1 0,85 (down), 0,15 (up) ligne des récepteurs, depuis le haut
scroll down, up down les notes descendent vers les récepteurs, ou montent
noteSize 0 à 0,5 par côté 0,0725 × 0,0725
receptorSize 0 à 0,5 0,0775 × 0,0775
holdWidth 0 à 0,5 0,02975 largeur du corps des holds
holdMatchNoteWidth booléen false corps de hold de la largeur de la note de la colonne, embout de la largeur du corps (holdWidth et la largeur de holdEndSize ignorées)
holdWidthScale 0,25 à 3 1 facteur de la largeur de note quand holdMatchNoteWidth est actif
holdEndSize 0 à 0,5 0,0725 × 0,015 taille de la fin de hold (aussi par colonne) ; hauteur 0 : pas d'extrémité
holdEndScale 0,1 à 3 1 multiplie la taille résolue de la fin de hold ; purement visuel
holdEndFlip booléen false retourne l'image de fin de hold par rapport à l'orientation automatique ; purement visuel
holdEndOffset −0,5 à 0,5 0 décale l'extrémité du hold le long du défilement, positif vers le récepteur ; purement visuel
noteOffsetY −0,5 à 0,5 0 décale notes et têtes de hold le long du défilement, positif dans le sens des notes ; purement visuel
borderWidth 0 à 0,1 0 cadre autour de la boîte des colonnes
borderColor couleur blanc couleur du cadre
judgementLine booléen false trace une ligne sur chaque colonne à la ligne de jugement (celle des récepteurs)
judgementLineColor couleur blanc couleur de cette ligne
judgementLineThickness 0,0005 à 0,02 0,003 épaisseur de cette ligne, en hauteurs d'écran
judgementLineAt center, top, bottom center où la ligne se place sur les récepteurs : au milieu, sur leur bord haut ou leur bord bas (tels que l'écran les montre)
laneCover booléen false cache de colonnes (lane cover) : un bloc de couleur sur toute la largeur des colonnes, dessiné au-dessus des notes, côté apparition des notes (en haut quand elles tombent, en bas quand elles montent) ; il ne dépasse jamais le bord des récepteurs, qui restent visibles ; purement visuel (jugement, replays et timing des holds intacts)
laneCoverColor couleur #000000 couleur du cache
laneCoverOpacity 0 à 1 1 multiplie l'alpha de la couleur du cache
laneCoverSize 0 à 1 0,3 jusqu'où le cache descend depuis le bord de la boîte des colonnes où les notes apparaissent, en fraction de la hauteur de cette boîte
laneCoverFeather 0 à 0,2 0 bord adouci : la fin du cache s'estompe sur cette longueur (fraction de la hauteur de la boîte, jamais plus que le cache lui-même) ; 0 : bord net
measureLines booléen false lignes de mesure : une ligne sur toute la largeur du playfield à chaque début de mesure de la map, qui défile comme les notes (même vitesse, même sens, zoom et rotation compris), dessinée sous les récepteurs et sous les notes, au-dessus du fond des colonnes ; purement visuel
measureLineColor couleur blanc couleur des lignes de mesure
measureLineOpacity 0 à 1 0,35 multiplie l'alpha de cette couleur
measureLineThickness 0,0005 à 0,02 0,003 épaisseur des lignes de mesure, en hauteurs d'écran
measureLineLength playfield, lanes playfield playfield : une ligne continue sur toute la largeur du playfield (les espaces entre colonnes remplis) ; lanes : un segment par colonne, de la largeur de la colonne, sur la ligne de réception de cette colonne (offsetY compris)
measureLineOvershoot 0 à 0,5 0 dépassement de part et d'autre des colonnes extérieures, en hauteurs d'écran (playfield seulement)
measureLineEvery 1 à 16 (entier) 1 une mesure sur N est accentuée (deux fois plus épaisse, à l'alpha plein de la couleur), en comptant depuis la première mesure ; 1 : aucune accentuation
beatLines booléen false lignes de temps : une ligne plus discrète à chacun des autres temps de la mesure, sous les lignes de mesure
beatLineColor couleur blanc couleur des lignes de temps
beatLineOpacity 0 à 1 0,12 multiplie l'alpha de cette couleur
beatLineThickness 0,0005 à 0,02 0,002 épaisseur des lignes de temps, en hauteurs d'écran
backgroundColor couleur transparent fond de la boîte des colonnes, visible entre les colonnes ; teinte backgroundImage
backgroundImage chemin .png aucune image étirée sur la boîte des colonnes
backgroundTint couleur #818181 multiplie l'image de fond de la map (22 %, l'assombrissement historique)
laneColor couleur #454d61 fond de chaque colonne ; teinte laneImage
noteColor, receptorColor, pressedColor, holdBodyColor, holdEndColor couleur blanc teintes multipliées avec l'image
holdMissedStyle tint, hide tint une note longue dont la tête n'a jamais été touchée, ou relâchée trop tôt (état « Miss » du cœur de jeu, définitif pour la note), reste à l'écran assombrie jusqu'à sortir de l'écran (tint, y compris si le joueur rappuie), ou disparaît comme avant (hide) ; également par colonne
holdMissedColor couleur #808080 teinte multipliée avec la tête, le corps et l'extrémité d'une note longue ratée (les couleurs se multiplient en lumière linéaire : #808080 garde environ un cinquième de la luminosité) ; également par colonne
holdMissedOpacity 0 à 1 1 multiplie l'alpha d'une note longue ratée ; également par colonne
note, receptor, receptorPressed, holdBody, holdEnd chemin .png rectangle blanc, sans image images de toutes les colonnes
laneImage chemin .png aucune image étirée sur chaque colonne
keyLightOn booléen true lumière de touche : le récepteur réagit à sa touche, même sur une colonne sans note ; false : il ne réagit pas (l'image receptor reste affichée)
receptorPressed chemin .png receptor image de la lumière de touche (le récepteur pressé)
pressedColor couleur blanc couleur de la lumière de touche
keyLightOpacity 0 à 1 1 multiplie l'alpha de la couleur de la lumière de touche
keyLightScale 0,25 à 3 1 échelle du récepteur pressé autour de son centre (1 : sa taille)
keyLightFadeMs 0 à 500 0 durée (ms) de l'apparition et de la disparition de la lumière de touche ; 0 : instantané, comme avant
stageLightOn booléen true éclairage de colonne ; false n'en dessine aucun, même avec une image stageLight
stageLight chemin .png aucune image d'éclairage de chaque colonne tant que sa touche est tenue
stageLightColor couleur blanc teinte de stageLight
stageLightOpacity 0 à 1 1 multiplie l'alpha de l'éclairage de colonne
stageLightSize 0 à 0,5 par côté 0 × 0 taille de l'éclairage ; 0 : largeur de la colonne, hauteur selon l'image
stageLightScale 0,1 à 3 1 facteur appliqué à la taille résolue ; le bord proche des récepteurs reste sur leur ligne
stageLightOffsetY −0,5 à 0,5 0 décale l'éclairage le long du défilement (positif : sens des notes)
stageLightFadeMs 0 à 500 100 durée (ms) de l'apparition et de la disparition de l'éclairage de colonne ; 0 : instantané
hitLight chemin .png avec {n} aucune éclat de frappe : animation jouée une fois au niveau des récepteurs de la colonne, à chaque jugement de cette colonne qui n'est pas un Miss (note, tête de hold, relâchement de hold jugé), relancée par la frappe suivante ; {n} est remplacé par 0, 1, 2…
hitLightOn all, off all hitLight joue à chaque frappe (all) ou jamais (off) ; le holdLight est un effet à part, il boucle tant qu'une note longue est tenue
hitLightFollowJudgement booléen false hitLight prend la couleur du palier touché (couleurs du jugement actif) au lieu de hitLightColor ; l'opacité reste appliquée
hitLightFrames 1 à 32 (entier) 1 nombre d'images de hitLight
hitLightFps 1 à 240 60 images par seconde (temps réel)
hitLightSize 0 à 0,5 par côté 0 × 0 0 : deux fois la largeur de la colonne, hauteur selon l'image
hitLightOffsetY −0,5 à 0,5 0 décale l'animation le long du défilement (positif : sens des notes)
hitLightColor couleur blanc teinte de l'animation
hitLightOpacity 0 à 1 1 multiplie l'alpha de l'animation
hitLightScale 0,1 à 3 1 facteur appliqué à la taille résolue (autour du centre de l'image)
holdLight, holdLightFrames, holdLightFps, holdLightSize, holdLightOffsetY, holdLightColor, holdLightOpacity, holdLightScale comme hitLight* aucune lumière de hold : animation en boucle tant qu'un hold de la colonne est tenu (de sa tête jugée sans Miss à son relâchement ou sa fin)
textureFilter linear, nearest linear filtrage des textures du playfield
lanes 32 au plus — valeurs par colonne (LaneSpec)

LaneSpec a les treize champs d'image et de teinte des colonnes ci-dessus (note, receptor, receptorPressed, holdBody, holdEnd, laneImage, stageLight, laneColor, noteColor, receptorColor, pressedColor, holdBodyColor, holdEndColor, stageLightColor) pour une seule colonne, les valeurs noteOffsetY, holdEndOffset, holdEndSize, holdEndScale, holdEndFlip, stageLightOffsetY, stageLightSize, holdMatchNoteWidth et holdWidthScale de la colonne, les réglages de la lumière de touche et de l'éclairage de colonne (keyLightOn, keyLightOpacity, keyLightScale, keyLightFadeMs, stageLightOn, stageLightOpacity, stageLightScale, stageLightFadeMs), l'aspect des notes longues ratées de la colonne (holdMissedStyle, holdMissedColor, holdMissedOpacity), et offsetX (−2 à 2 hauteurs d'écran, 0 par défaut) qui décale toute la colonne vers la droite (fond, récepteur, notes, holds). receptorPressed absent : l'image receptor du même niveau sert aussi quand la touche est enfoncée. Les couleurs s'écrivent #rgb, #rgba, #rrggbb ou #rrggbbaa ; une teinte multiplie son image dans le shader. Une note parcourt 0,8 hauteur d'écran pendant le temps de défilement réglé par le joueur.

La ligne de jugement (judgementLine) est un segment par colonne, de la largeur exacte de la colonne, centré sur la ligne des récepteurs de cette colonne (décalage offsetY compris) : il suit donc le zoom, la rotation et le placement libre des colonnes, se dessine au-dessus du fond des colonnes et sous les récepteurs, et ne change ni le jugement ni la position des hits. Comme les autres valeurs, elle se règle par le script du skin, le script de la map, puis par la configuration du joueur (éditeur de skin, groupe « Ligne de jugement »).

Le cache de colonnes (laneCover) est un seul bloc pour tout le playfield, sur la largeur de la boîte des colonnes, qui suit le zoom et la rotation. Il est dessiné après les notes, donc par-dessus elles, mais jamais sur la zone des récepteurs : quelle que soit laneCoverSize, il s'arrête au bord des récepteurs le plus proche de l'apparition des notes. L'adoucissement (laneCoverFeather) est rendu par 24 bandes d'alpha décroissant dans le même lot d'instances que le reste (pas de shader dédié). Le joueur le règle en direct dans l'éditeur de skin, dans un onglet à part, « Cache des colonnes » (groupe laneCover, à côté de « Ligne de jugement »), position (laneCoverSize) et couleur comprises. Ce groupe fait partie du catalogue de l'hôte (field_catalog), pas du skin : tous les skins l'ont, le skin par défaut, Default Circle, ceux du joueur, y compris un skin minimal, désactivé par défaut. Il se règle sur le playfield (pas sur une colonne : sélectionner une colonne n'affiche que ses propres champs). Réglages → Vidéo → Playfield montre son état pour le skin actif (désactivé, activé avec sa taille, ou « selon le skin » tant que le joueur n'a rien choisi) et un bouton-icône « Ouvrir dans l'éditeur de skin » : il envoie editLayout { focus: "laneCover" } et l'éditeur s'ouvre sur le playfield, onglet « Cache des colonnes ». Les valeurs restent dans skinConfig[skinId] : la page Réglages n'en garde aucune copie. Rien n'est jugé ni enregistré différemment : seul le rendu change.

Avec l'option du joueur « Suivre les changements de vitesse (SV) » (docs/game.md, désactivée par défaut), les lignes de mesure et de temps suivent la même carte de défilement que les notes ; les valeurs du skin ne changent pas.

Les lignes de mesure (measureLines) et de temps (beatLines) sont natives : le moteur les dessine, sans mod ni primitive de scène. Elles sont désactivées par défaut, y compris dans le skin default (un skin les allume avec measureLines: true dans son setupPlayfield, la map avec playfield.update, le joueur dans l'éditeur). Le calendrier des lignes est calculé une fois au lancement de la partie, sur le fil du moteur (pas celui du rendu), à partir des points de timing de la chart déjà mise à la vitesse de jeu : un point BPM commence une mesure, les temps suivent toutes les 60 / bpm secondes jusqu'au point suivant, une mesure compte signature temps (1 à 64, sinon 4), et avant le premier point son tempo se prolonge vers l'arrière jusqu'à 0. Les lignes sont donc justes à n'importe quel rythme (rate) et suivent les changements de BPM. Même sémantique que game.beat des mods. À chaque image, le rendu ne fait que deux recherches dichotomiques dans ce calendrier (borné à 100 000 lignes) pour la fenêtre visible, sans allocation, et dessine au plus 256 lignes de mesure et 256 lignes de temps : la position d'une ligne est celle d'une note du même instant (temps de défilement, sens, décalage visuel, zoom, rotation, receptorY), dans le même lot d'instances que le reste. Elles apparaissent dans le jeu, le replay, le visualiseur, l'aperçu et la session d'édition du skin (la démo a un BPM : 150), pas dans l'éditeur de map, qui a sa propre grille. Ce qui est supporté, selon la source :

  • osu! : chaque point de timing non hérité donne son BPM et sa signature (meter) ; une signature absurde vaut 4.
  • StepMania / Etterna (.sm, .ssc) : les changements de BPM comptent, la mesure a toujours 4 temps (#TIMESIGNATURES n'est pas lu). Les arrêts (#STOPS), délais et warps sont déjà intégrés aux temps des notes par l'import, sans point de timing : les lignes ne les montrent pas (elles suivent le tempo seul).
  • Une chart sans point BPM n'a pas de lignes ; deux points au même instant : le dernier gagne ; un BPM au-delà de 60 000 est ignoré.

Les valeurs restent dans skinConfig[skinId] (précédence : script du skin, script de la map, configuration du joueur, en direct). Réglage dans l'éditeur de skin, onglet « Lignes de mesure » (groupe measure, deux sections : « Lignes de mesure » et « Lignes de temps »), sur le playfield uniquement : aucune de ces valeurs n'existe par colonne. Rien n'est jugé ni enregistré différemment.

Les notes longues ratées (holdMissedStyle) lisent l'état déjà connu du cœur de jeu (tête jamais touchée, ou relâchement trop tôt) sans le modifier ; ni le jugement ni le format des replays ne changent, et le replay montre donc la même chose que la partie. Dans l'éditeur de skin (groupe « Couleurs », section « Notes longues ratées »), la démo d'édition affiche toujours un hold comme s'il était raté (le premier dont la tête est encore devant les récepteurs, sans rien juger) pour voir l'effet en direct.

Largeur des holds suivant les notes. Avec holdMatchNoteWidth (booléen, false par défaut ; les skins default et default-circle le mettent à true), le corps d'un hold fait la largeur de la note de sa colonne (sa taille résolue : noteSize du skin, de la map, du joueur ou de la colonne) multipliée par holdWidthScale (0,25 à 3, 1 par défaut) et l'embout fait la largeur du corps ; holdWidth et la largeur de holdEndSize sont alors ignorées. La hauteur de l'embout suit les proportions de son image, sauf si un skin ou le joueur donne holdEndSize (sa hauteur s'applique alors). Agrandir les notes dans l'éditeur agrandit donc corps et embouts ensemble, en direct. Les deux valeurs existent aussi par colonne (LaneSpec).

Décalages visuels. noteOffsetY et holdEndOffset (−0,5 à 0,5 hauteur d'écran, 0 par défaut) déplacent des images le long de l'axe de défilement, sans jamais toucher au jugement, au timing des holds, au score ni aux replays (comme le décalage visuel du joueur). noteOffsetY déplace les notes et les têtes de hold : positif, dans le sens où les notes avancent (vers les récepteurs et au-delà) ; négatif, vers l'arrière. 0 centre la note sur la ligne des récepteurs, comme dans Default Circle où la ligne de jugement est le milieu de la note. Le corps et l'embout du hold ne suivent pas : ils restent sur le temps de la fin. holdEndOffset déplace l'image d'extrémité : positif, vers le récepteur (« 30 px avant la fin » est une valeur positive), négatif, à l'opposé. Une extrémité dont la fin est hors de la zone dessinée n'est pas dessinée (elle n'est plus collée au bord de l'écran). L'image d'extrémité s'écrit comme une note : sa pointe (le bas de l'image) vise les récepteurs quand les notes descendent, comme dans les skins osu! ; le jeu la retourne pour qu'elle pointe à l'opposé des récepteurs dans le repère du playfield : retournée quand les notes descendent, telle quelle quand elles montent, pour toute rotation et tout zoom.

Fin de hold (onglet de l'éditeur). L'image de fin d'un hold a son propre groupe dans le catalogue de l'éditeur (holdEnd, une seule section holdEnd, onglet « Fin de hold ») : holdEnd (image), holdEndColor (teinte), holdEndSize (taille, par colonne aussi), holdEndScale (0,1 à 3, 1 par défaut : multiplie la taille résolue, la position de la fin ne bouge pas), holdEndOffset (décalage le long du défilement) et holdEndFlip (booléen, false : retourne l'image par rapport à l'orientation automatique ci-dessus). Chaque champ existe pour tout le playfield et par colonne (LaneSpec), avec la précédence habituelle (skin < script de la map < joueur ; la valeur propre d'une colonne l'emporte sur celle du playfield de la même couche), en direct dans l'éditeur, et reste purement visuelle : jamais de jugement, de timing de hold, de score ni de replay. La largeur suit le corps quand holdMatchNoteWidth est actif ; sa hauteur vient de l'image sauf si holdEndSize la donne. Dans la démo d'édition, chaque fin de hold à l'écran est un cadre qu'on clique pour la sélectionner (surlignage aux couleurs du thème, onglet « Fin de hold » seul, valeurs de cette colonne) ; on la fait glisser le long de la colonne (ou avec les flèches haut/bas) pour régler holdEndOffset, Suppr remet les valeurs de fin de hold de la colonne à celles du skin. La ligne « Fin de hold » de la liste des calques sélectionne les fins de toutes les colonnes (valeurs du playfield) ; l'œil masque les cadres. Les cadres viennent de l'hôte (editLayout.holdEnds, des HoldEndBox normalisées comme laneBoxes), calculés par le même code que le dessin (Layout::hold_end_rect), donc le cadre est exactement l'image dessinée (zoom compris).

Éclairage de colonne (stage light). Avec une image stageLight, chaque colonne s'éclaire tant que sa touche est tenue : un quad de la largeur de la colonne (stageLightSize.width à 0) et de la hauteur de stageLightSize.height, ou du rapport de l'image si elle vaut 0, multipliés par stageLightScale, teinté par stageLightColor et d'alpha multiplié par stageLightOpacity, dessiné au-dessus du récepteur et sous les notes, son bord le plus proche des récepteurs sur la ligne des récepteurs de la colonne (décalage stageLightOffsetY compris, positif dans le sens de défilement des notes) et s'étalant du côté d'où viennent les notes. Son alpha monte de 0 à 1 touche tenue et retombe linéairement après le relâchement en stageLightFadeMs (100 ms par défaut ; 0 : instantané), en temps réel de l'image, calculé par le moteur de rendu sans allocation ni script par frame. stageLightOn (booléen, true par défaut, couches skin < map < joueur, en direct, par colonne aussi) le coupe sans toucher à l'image, à la couleur ni aux animations. Sans image stageLight (défaut) rien n'est dessiné. Le skin Default Circle n'en a pas : ses images d'éclairage (lightingN, lightingL, lighting, mania-stage-light) sont des pixels transparents de 1×1 dans le .osk d'origine.

Lumière de touche (key light). C'est la réaction du récepteur à sa touche, même sur une colonne sans note : le mécanisme du récepteur pressé, dont les noms historiques sont conservés. Correspondance : image de la lumière de touche = receptorPressed, couleur = pressedColor, auxquels s'ajoutent keyLightOn (false : le récepteur ne réagit pas à la touche), keyLightOpacity (alpha de la couleur), keyLightScale (échelle du récepteur pressé autour de son centre) et keyLightFadeMs (fondu, 0 par défaut : le changement instantané d'avant). Pendant un fondu, le récepteur normal reste dessiné sous l'image pressée, dont l'alpha suit l'enfoncement ; le fondu part du temps réel de l'image, comme celui de l'éclairage de colonne. Tout cela existe aussi par colonne. L'éditeur les regroupe dans la section « Éclairage de touche » du groupe « Éclairage ».

Renommage et migration des valeurs enregistrées. L'éclairage de colonne s'appelait light, lightColor, lightSize et lightOffsetY ; keyLightOn a brièvement désigné son interrupteur sans jamais être livré. Les configurations du joueur déjà enregistrées avec les anciens noms light* (playfield, colonnes et modifications) sont lues une seule fois sous les nouveaux noms stageLight* (alias de désérialisation, migration ponctuelle de données enregistrées) et réécrites sous ces nouveaux noms à la prochaine sauvegarde. Il n'y a aucun autre alias : les scripts de skin et de map doivent utiliser stageLight*, et l'ancien keyLightOn n'est pas migré (il désigne maintenant la lumière de touche).

hitLightOn. all (défaut, comportement des autres skins) joue l'éclat à chaque jugement non Miss de la colonne. holds ne le joue que pour la tête d'un hold touchée (non Miss) : jamais pour une note simple, jamais à un Miss, et pas non plus au relâchement, même réussi (tête seule). Le moteur de rendu le déclenche quand un hold de la colonne commence à être tenu, l'état qui pilote déjà la lumière de hold, donc quel que soit le modèle de holds du jugement (avec les modèles à jugement unique, la tête n'émet aucun jugement). Limite : un hold commencé et relâché entre deux images n'affiche pas l'éclat. Se règle comme les autres valeurs (skin < map < joueur, en direct, groupe « Éclairage » de l'éditeur). off ne dessine aucun éclat de frappe, quel que soit le skin : le joueur peut ainsi désactiver l'éclat bleu sans toucher à la lumière de hold (boucle, réglée par holdLight) ni à l'éclairage de colonne (stageLight) ni à la lumière de touche, qui gardent leurs propres champs.

Éclat de frappe et lumière de hold. hitLight et holdLight sont des animations par image, sur toutes les colonnes (elles n'existent pas par colonne, contrairement à stageLight). Un motif de chemin porte le jeton {n}, remplacé par 0, 1, 2… pour chaque image (fx/spark-{n}.png) ; …Frames en donne le nombre (1 par défaut, 32 au plus) : une liste d'images dans l'éditeur demanderait un nouveau type de champ, alors que le motif tient dans un champ texte et un nombre, et se règle aussi pour un skin du joueur (player:boom-{n}.png). Un seul fichier suffit pour une animation d'une image (…Frames à 1, sans jeton). Chaque image est un PNG du dossier du skin ; le motif est vérifié en Rust (chemin sûr, .png, {n} obligatoire au-delà d'une image), au plus 256 images distinctes par couche. L'éclat se joue une fois depuis son image 0 à …Fps images par seconde, redémarre à chaque nouveau jugement non Miss de la colonne, puis disparaît ; la lumière de hold boucle tant qu'un hold de la colonne est tenu et s'arrête au relâchement ou à la perte du hold. Le temps est le temps réel de l'image (comme le fondu de stageLightFadeMs : la cadence ne dépend ni du rate ni de l'horloge, figée dans l'éditeur), sans allocation ni script par frame. L'éclat part des jugements que le moteur de rendu reçoit déjà pour les stage.* (colonne, Miss ou non) et la lumière de hold de l'état des holds de la partie : rien de tout cela ne touche au jugement, au score ni aux replays. Les images sont centrées sur la ligne des récepteurs de la colonne (décalage …OffsetY compris, positif dans le sens des notes), au-dessus des récepteurs et de l'éclairage de colonne, sous les notes, retournées comme stageLight quand les notes montent (écrites pour des notes qui descendent). Largeur : 2 colonnes si …Size.width vaut 0, hauteur selon les proportions de l'image courante. Les couches se résolvent par valeur (script du skin < script de la map < configuration du joueur, en direct dans l'éditeur, groupe « Éclairage »). Le motif, retenu au niveau le plus haut dont la première image existe, prend autant d'images consécutives qu'il en trouve jusqu'au nombre demandé (le plus haut niveau qui en donne un, sinon 1) : une image manquante est signalée et raccourcit l'animation. Le mélange est le mélange alpha normal du moteur (un seul lot d'instances, pas de shader propre) : un mélange additif n'y entre pas, et les images d'éclat de default (cyan clair, alpha jusqu'à 254) s'y affichent correctement sur le fond sombre.

Le filtrage des textures est un réglage du skin, textureFilter : linear (par défaut) échantillonne en bilinéaire, les sprites agrandis ou réduits restent lisses ; nearest garde des pixels nets (skins en pixel art). L'atlas réplique le bord de chaque image dans une gouttière de 2 px qui lui est propre (les pixels totalement transparents prennent la couleur de leurs voisins opaques) : aucun voisin ne déborde et aucune frange sombre n'apparaît au bord. Pas de mipmaps : le bilinéaire convient jusqu'à une réduction d'environ 2× (les images sont ramenées à 640 px au plus). Le joueur le change dans l'éditeur de skin (groupe « Rendu »), en direct ; le rendu natif des polices reste distinct.

Apparence par défaut du moteur (valeurs absentes ; le skin default définit toutes les siennes) : les colonnes prennent tour à tour les flèches left, down, up, right du skin default (une chacune en 4K, répétées au-delà), chacune avec la touche de sa flèche (keys/<flèche>.png et keys/<flèche>-d.png), le corps notes/body.png et l'embout notes/tail-cap.png. Ces touches sont hautes (198 × 566 px, flèche au centre) : sans receptorSize du skin elles sont étirées dans le carré de 0,0775 de l'apparence par défaut du moteur, il faut donc donner une hauteur de 2,86 fois la largeur comme le fait skins/default/skin.ts. La couleur des colonnes, historiquement une valeur linéaire, est l'arrondi 8 bits #454d61 (écart < 0,002).

Résolution et erreurs

Chaque valeur d'une colonne se résout dans l'ordre : lanes[colonne], le champ de playfield.set, puis l'apparence par défaut du moteur. Le script de la map, s'il y en a un, dessine une couche au-dessus : chaque valeur qu'il donne l'emporte sur celle du skin. Rien d'invalide n'empêche la partie :

  • nombre hors bornes : ramené dans les bornes ; non fini : valeur par défaut ;
  • couleur ou chemin invalide : valeur suivante de la chaîne ;
  • image absente, illisible, non PNG, de plus de 4 Mio ou de plus de 2048 px de côté : pas d'image (rectangle blanc) ;
  • colonnes en trop dans lanes : ignorées ;
  • script en erreur, defineSkin invalide ou setup au-delà du budget : le premier skin disponible est joué à sa place (sans aucun skin, les valeurs par défaut du moteur) ; sans réponse du thread des mods en 1 s, la partie démarre avec l'apparence par défaut du moteur.

Chaque problème est signalé : message dans le journal des mods pendant setup, bandeau d'erreur au lancement et liste sur la page Skins (le skin utilisé affiche les problèmes de son dernier lancement).

Couche du joueur

Au-dessus du skin et du script de la map, le joueur personnalise chaque skin séparément (éditeur de skin, réglage skinConfig[skinId] des paramètres, voir Configuration du joueur par skin) : tout le playfield, colonne par colonne, et les widgets du HUD. Chaque valeur présente l'emporte sur celle du skin et de la map, y compris les playfield.update en cours ; les autres suivent le skin. Les valeurs hors bornes sont ramenées dans les bornes, les non finies retirées. La couche s'applique à chaque lancement d'une partie et en direct pendant l'édition : la séance d'édition (Command::EditLayout) joue la démo d'édition figée à 2 s (Song::edit_demo, réservée aux séances d'édition : des notes simples et des holds de longueurs différentes, dont un dont la tête est sur la ligne des récepteurs, un dont le corps la traverse et un dont la fin tombe exactement dessus, pour voir et régler corps, tête, fin de hold (au moins trois colonnes en ont une à l'écran), largeur des holds, noteOffsetY), sans jugement, sans score ni replay, jusqu'à Échap. Un aperçu des lumières, activé au début de la séance (overlayEditPreview { lights }, état renvoyé dans editLayout.previewLights), fait dessiner par le moteur de rendu, sans rien juger, la lumière de touche (récepteurs pressés) et l'éclairage de colonne sur toutes les colonnes, la lumière de hold sur les colonnes dont un hold traverse la ligne des récepteurs et l'éclat de frappe en boucle (relancé toutes les 1,2 s, selon hitLightOn : all toutes les colonnes, holds celles qui ont un hold, off aucune ; keyLightOn et stageLightOn s'appliquent) ; désactivé, le playfield reste net.

Changer le playfield pendant la partie

Depuis ses gestionnaires d'événements (ctx.on("game.judgement", …), game.tick, game.pause…, enregistrés dans setup), un skin change n'importe quel champ du playfield, toutes les couleurs et toutes les images comprises :

setup(play: PlayContext) {
  ctx.on("game.judgement", (judgement) => {
    lanes.set({ column: judgement.column, lane: { receptorColor: judgement.tier.color },
      transitionMs: 0 });
    if (judgement.combo % 100 === 0) {
      playfield.update({ patch: { note: "notes/gold.png", laneWidth: 0.09 },
        transitionMs: 300, easing: "easeOut" });
    }
  });
}
  • playfield.update({ patch, transitionMs?, easing? }) : patch est un PlayfieldSpec partiel (les champs donnés changent, lanes compris). lanes.set({ column, lane, transitionMs?, easing? }) fait de même pour une colonne. transitionMs va de 0 à 10 000 (absent ou 0 : immédiat) ; easing vaut linear (défaut), easeIn, easeOut ou easeInOut.
  • Nombres et couleurs passent de la valeur affichée à la nouvelle pendant la transition ; les images, l'ancrage et le sens de défilement changent tout de suite. Chaque valeur a sa propre transition : un changement n'interrompt que celles des valeurs qu'il modifie. La transition suit l'horloge de la chanson : elle s'arrête pendant une pause.
  • Images préchargées : seules les images chargées au lancement peuvent être affichées : celles que le playfield.set de setup nomme et celles que defineSkin({ images: [...] }) déclare (64 au plus). Une image non chargée lève une erreur dans le script ; le rendu ne décode jamais rien.
  • Chaque appel est validé (bornes, couleurs, chemins, colonne < 32) ; une erreur est levée dans le script. Le nombre de colonnes ne change pas et le jugement n'est pas affecté : ces changements sont visuels.
  • Les changements d'un même pas du thread des mods (un lot d'événements) qui ont la même transition sont fusionnés en un PlayfieldPatch (les derniers champs gagnent) ; ceux de transitions différentes restent séparés, dans l'ordre (8 au plus par pas, au-delà ils rejoignent le dernier). Ils partent par une file bornée de 64 que le thread des mods remplit sans jamais attendre ; si elle est pleine, les changements sont gardés pour le pas suivant.
  • Le thread de rendu vide la file au début de chaque image, résout le nouveau playfield avec les images déjà dans l'atlas (aucun envoi de texture : les teintes sont des multiplications du shader, la teinte de fond est un uniforme de 16 octets) et anime la transition image par image. Sans changement ni transition, une image lit une case de la file et n'alloue rien.
  • L'entité game.playfield() des mods reste la disposition du lancement.

Dessin natif : stage.*

Un skin qui déclare permissions: ["stage"] dans defineSkin peut aussi dessiner dans le rendu natif avec stage.*, à l'image près avec les notes : sprites, rectangles, textes, émetteurs de particules et déclencheurs évalués par le thread de rendu (référence : modding). Sans la permission, chaque appel stage.* lève une erreur. Un sprite ou un émetteur ne peut nommer qu'une image déclarée dans defineSkin({ images }) ; ces images sont décodées avec celles du playfield, dans le même atlas. Comme le reste du script, les éléments du skin repartent de zéro à chaque lancement de map.

Chargement sans ralentir le jeu

lancement de map (thread principal)
  └─ thread d'aide :
       files des changements de la partie : skin::patch_channel(), une pour le
       skin, une pour le script de la map
       prepare_play(PlayContext, émetteurs) ──> thread mods : skin.ts rechargé,
         setup(play), puis le script de la map (voir map-scripts.md)
       attend les PlayfieldSpec et la liste des images (1 s au plus)
       skin::prepare : validation, décodage PNG (skin, script de la map,
         images stage des mods), un seul atlas
  └─ thread principal : RenderCommand::Skin { prepared, patches,
       chart_patches } puis Start
thread de rendu : un upload de texture avant la première image ;
                  à chaque image : changements reçus, transitions, dessin

Les images (celles du playfield.set et les images déclarées) sont décodées puis réduites (en gardant leurs proportions) à 640 px de côté au plus et rangées dans un atlas de 2048 px de large et de 4096 px de haut au plus ; 256 images au plus par couche (skin, script de la map), une image qui n'y tient pas est ignorée (rectangle blanc). Le thread de rendu ne reçoit que l'atlas prêt et la disposition résolue : il ne lit ni ne décode rien, et une image sans changement n'alloue rien.

Convertir un skin osu!

Le mod mods/skin-converter ajoute à la page Skins une carte « Convertir un skin osu! » : le joueur choisit un dossier de skin osu!mania ou un .osk, la carte montre les dispositions 4K à 7K qui seront créées, ce que Prism ne sait pas faire (tête de hold distincte, NoteBodyStyle ≠ 0, lignes de colonne, images de jugement, de combo et de score, sons…) et le poids du skin ; « Créer le skin » écrit un dossier ordinaire dans <données>/skins/ (jamais en remplaçant un skin existant), puis « Ouvrir dans l'éditeur de skin ». Les valeurs suivent celles du skin default converti à la main (ColumnWidth → laneWidth, ColumnSpacing → laneGap, HitPosition → receptorY au milieu des notes, KeyImage calées sur la place qu'osu! leur donne, StageLight/LightingN/LightingL → éclairages, Colour{N} → laneColor, ScorePosition et ComboPosition → HUD) ; les écarts connus sont listés dans le test d'or crates/modding/tests/skin_converter.rs. Un nombre de touches sans section convertie joue avec la disposition convertie la plus proche. Conception : convertisseur-skin-osu.md.

Gérer les skins

Page Skins (menu Échap) : liste avec aperçu (preview.png du skin, sinon son left.png), nom, version et auteur déclarés par defineSkin, skin utilisé, problèmes ; boutons Utiliser, Exporter, Désinstaller, Importer…, Ouvrir le dossier, Recharger. Changer de skin redémarre l'hôte des mods (hors partie) ; il sert à partir du lancement suivant.

  • Réglage skin (nom de dossier, default par défaut), enregistré avec les autres réglages et validé par Rust : un nom invalide devient default ; un dossier absent est signalé et le premier skin disponible est utilisé. Chaque skin a sa propre configuration du joueur (skinConfig[<id>]). prism.exe --skin <id> (benchmark, smoke test) choisit n'importe lequel.
  • Désinstaller supprime le dossier du skin, y compris un skin du jeu de skins\ à côté de prism.exe s'il est accessible en écriture (sinon une erreur nomme le chemin).
  • Exporter écrit <id>.zip dans un dossier choisi (fichiers cachés exclus ; tout autre fichier doit être .ts, .png, .ttf ou .otf).
  • Importer installe un .zip choisi comme nouveau dossier, sans jamais remplacer un skin existant : nom du dossier racine de l'archive (ou du fichier), suffixé -2, -3… si besoin. Toute l'archive est vérifiée avant d'écrire : chemins relatifs sûrs (pas de zip-slip, de chemin absolu, de \, de nom réservé), pas de lien symbolique ni de chiffrement, extensions autorisées, doublons de casse refusés, 64 Mio par archive, 512 fichiers, 32 Mio par fichier et 64 Mio au total, taille réelle de chaque fichier contrôlée pendant l'extraction ; skin.ts requis à la racine (ou dans l'unique dossier racine). L'extraction se fait dans un dossier caché puis un renommage.
  • Recharger relit la liste ; les fichiers d'un skin sont relus à chaque lancement de map.

prism.exe --bench-gameplay 10 --skin <id> et --smoke-test --skin <id> jouent avec le skin <id> (les réglages de l'interface arrivent après le démarrage du benchmark).

Contrat avec l'interface

  • Événement { type: "skins", dir, skins: [{ id, name, author, version, preview, errors }], selected, errors } : selected est le skin du prochain lancement, errors les entrées du dossier qui ne sont pas des skins, preview un chemin /skins/<id>/<fichier> servi par le protocole prism.
  • { type: "skinOperation", action: "import" | "export", id, path } après une opération réussie ; les échecs arrivent en error.
  • Commandes selectSkin { id }, reloadSkins, openSkinsFolder, importSkin, exportSkin { id }.

Exemple : notes roses

  1. Copier le dossier skins\default sous un autre nom (skins\rose) dans le dossier des skins du joueur, changer id et name dans son skin.ts, puis Recharger sur la page Skins.
  2. Dans rose\skin.ts, changer noteColor: "#ffffff" en noteColor: "#ff4fa3" (ou remplacer notes/left.png par une autre image).
  3. Utiliser ce skin et lancer une map.

Configuration du joueur par skin

Les réglages du joueur sont rangés par skin (Settings.skinConfig[skinId], 64 skins au plus) : playfield (SkinPlayfieldConfig) et elements (options des éléments HUD que le skin place). Le remplacement de l'ancien réglage global playfieldLayout est lu une seule fois : il est déplacé dans la configuration du skin sélectionné si celle-ci n'a pas de playfield, sinon abandonné.

  • Couches du playfield, la plus haute gagne valeur par valeur, en direct et au-dessus des transitions en cours : skin < script de map < configuration du joueur. Elle couvre x, y, zoom, rotation, largeur/écart/hauteur des lanes, ligne des récepteurs, sens de défilement, tailles (notes, récepteurs, fins de hold), bordure, couleurs, teinte du fond et, lane par lane (lanes, colonnes 0..31), offsetX, offsetY, width, tailles, couleurs et images. Les décalages d'une lane déplacent son fond, son récepteur, ses notes et ses holds, jamais le jugement.
  • Options d'un élément, un seul ordre : défauts déclarés < configure() du skin < skinConfig[skin].elements (éditeur) ; une valeur globale ne l'emporte jamais sur l'éditeur. Les réglages propres des mods (elementOptions, options player: true) ne s'appliquent qu'à un élément que l'éditeur ne peut pas placer, et restent sous skinConfig.
  • Images : une référence est un PNG du dossier du skin ou player:<fichier> (images importées par le joueur dans <données>/skins-config/<skin>/images/, PNG/JPEG/WebP, 4 Mo et 2048 px au plus, 64 par skin ; l'atlas les réduit à 640 px). La configuration du joueur est une couche séparée : elle ne modifie pas les fichiers du skin.
  • L'éditeur en jeu (overlayEditSkin, overlayEditElement, overlayImportSkinImage) travaille sur une copie de la configuration du skin actif ; chaque geste validé (commit) l'enregistre et émet skinConfigChanged.

Personnaliser un skin en jeu

Page Skins → Personnaliser le skin (carte du skin en cours) ouvre l'éditeur de skin : la démo figée tourne sous la surcouche, un clic sur le playfield, sur une colonne ou sur un widget du HUD le sélectionne et l'inspecteur propose ce qui se règle à cet endroit. Le catalogue des champs vient de l'hôte (fields, groupes placement / taille / couleur / image / éclairage / ligne de jugement / rendu, bornes et pas) : aucune borne n'est écrite deux fois dans l'interface. Chaque champ porte une section (identifiant stable en camelCase : position, lanes, scroll, notes, receptors, holds, background, keyLight, stageLight, hitLight, holdLight, line, texture) : l'interface affiche un petit titre par section dans un onglet (skin_editor.section_<id>), les champs d'une section étant listés ensemble dans l'ordre du catalogue.

  • Playfield : position (glisser), zoom, rotation, largeur, écart et hauteur des colonnes, ligne des récepteurs, sens de défilement, tailles des notes, des récepteurs et des holds, bordure, couleurs et teinte du fond.
  • Colonne par colonne : chaque colonne se place librement (offsetX, offsetY en hauteurs d'écran ; l'inspecteur les saisit en pixels de la fenêtre, « Centre X / Y » : l'une à 0 px, une autre à 100 px, une troisième à 1250 px), se redimensionne et a ses propres couleurs et images.
  • Images : note, récepteur, récepteur appuyé, corps et fin de hold, fond de colonne et fond du playfield se choisissent parmi les images du skin ou importées (player:<fichier>), ou s'importent depuis le disque.
  • Widgets du HUD : ce sont des mods ; chacun se sélectionne, se déplace, se redimensionne et se masque, par skin (skinConfig[skin].elements).

Un skin personnalisé se reconnaît sur la page Skins (badge, nombre de valeurs, colonnes, widgets et images importées) ; Réinitialiser la personnalisation retire sa configuration. Toutes les options d'un widget du HUD (décimales de la précision, visibilité, polices, couleurs, animations…) se règlent ici, skin par skin : l'éditeur est l'unique endroit. Réglages → Mods ne montre plus que les options player: true d'un mod dont l'élément n'a pas de place (ni x ni y numériques : l'éditeur ne peut pas le placer) ; la catégorie disparaît quand il n'y en a aucune. Les anciennes valeurs globales d'un élément que l'éditeur place sont reprises une fois dans la configuration du skin actif et des skins déjà configurés, sans jamais écraser une valeur du skin.

Fonctions de skin manquantes

Proposition, rien de ceci n'est implémenté. Comparaison avec les réglages skin.ini d'osu!mania (clés vérifiées sur le wiki d'osu!), les noteskins de StepMania/Etterna et le skin.ini de Quaver (ces deux dernières sources de mémoire, à confirmer avant de coder). Classées par intérêt décroissant ; effort : S (quelques heures), M (une journée), L (plusieurs jours) ; risque : régression de perf ou de format.

# Fonction Qui l'a Valeur Effort Risque
1 Notes colorées selon la subdivision (1/4 rouge, 1/8 bleu, 1/12 violet, 1/16 jaune…) : teinte ou image par subdivision, calculée à partir du calendrier de game-core::barlines StepMania/Etterna (noteskins par quantification), Quaver (ColorObjectsBySnapDistance) très forte : lecture de la rythmique, demandée par tous les joueurs de StepMania M : une subdivision par note calculée au lancement (hors rendu), une teinte par subdivision dans la configuration ; images par subdivision en L moyen : exactitude sur les changements de BPM, arrêts non pris en compte, une table de plus par partie
2 Séparateurs de colonnes, bordures et remplissage de scène (largeur et couleur individuelles) osu! (ColumnLineWidth, ColourColumnLine, StageLeft/StageRight/StageBottom) forte : un des éléments les plus visibles d'un skin osu! S : des quads fins entre les colonnes, sous les notes faible
3 Position et taille du combo et du jugement osu! (ComboPosition, ScorePosition), Quaver (ComboPosY, JudgementBurstPosY) moyenne S : ce sont des widgets de mods ; en faire des options de mod, pas des champs du skin faible
4 Variantes du hit light : image par tier de jugement, par colonne, hauteur propre (LightPosition) osu! (LightPosition, LightingN/LightingL), Quaver (HitLightingY, largeur/hauteur) moyenne : on a déjà hitLight/holdLight, la couleur du tier et le décalage S faible
5 Images selon le type de note : accord (plusieurs notes au même instant), colonne spéciale osu! (SpecialStyle, images NoteImage#), StepMania (jump) moyenne M : regrouper les notes simultanées au lancement moyen
6 Styles du corps des notes longues : étiré (actuel), répété depuis le haut ou le bas osu! (NoteBodyStyle), StepMania (corps étiré ou répété, capuchons) moyenne M : le shader n'a pas de répétition d'atlas, il faut des quads par tuile (bornés) moyen : nombre de quads
7 Notes et récepteurs animés (suite d'images) osu! (NoteImage#@N), StepMania, Quaver (AnimationFrameRate) moyenne : les lights le sont déjà, pas les notes M : un index d'image par note à chaque image, dans le chemin chaud moyen : perf, à mesurer avec --bench-gameplay
8 Image de touche par colonne sous ou sur les notes osu! (KeyImage#, KeysUnderNotes), Quaver (ReceptorsOverHitObjects) moyenne S à M : une couche par colonne et un ordre de dessin faible
9 Cache des colonnes côté récepteurs (lift / « hidden ») et réglage en jeu lecteurs de StepMania/Etterna (appearance), osu! (lazer : cover) moyenne : le laneCover actuel ne couvre que côté apparition S faible
10 Retourner les images des notes en défilement vers le haut (par type : tête, corps, queue) osu! (NoteFlipWhenUpsideDown#H/L/T), Quaver (FlipNoteImagesOnUpscroll) faible à moyenne : on a déjà holdEndFlip S faible
11 Mesures exactes pour StepMania : #TIMESIGNATURES, arrêts et warps comme points de timing dans l'import — (donnée, pas skin) moyenne pour les lignes de mesure des fichiers .sm M : étendre le format ROX (vendor/Rhythm-Open-Exchange) moyen : touche l'import et les replays

osu! a aussi ColourBarline/BarlineHeight (lignes de mesure, sans image), ColourJudgementLine et JudgementLine : déjà couverts par measureLines* et judgementLine*.

Ce qui devrait être un mod, ce qui reste moteur

Règle : moteur (natif) par défaut ; un mod seulement quand c'est une vraie surcouche optionnelle, composée de primitives existantes, et qui vaut la peine d'être amovible. Les lignes de mesure ont d'abord été envisagées comme un mod (primitive de scène + requête sur la chart) : rejeté, elles s'appuient sur le temps de défilement exact du rendu et un calendrier calculé au lancement, ce qui est du moteur. Restent dans le moteur, sans changement : cache des colonnes (laneCover), holdMissedStyle, ligne de jugement et son ancrage, lumières (scène, touche, hit, hold), image de fin de hold, couleurs par colonne, lignes de mesure et de temps. Sont déjà des mods : les widgets du HUD (jugement, précision, combo, compteurs, barre de progression, FPS, ghost…). Le sélecteur de note (RatingPicker) est de l'interface, pas du skin. Rien n'est à migrer.

Source dans le dépôt du jeu : docs/skins.md