Sur cette page

← Toute la documentation

Scripts de map

Scripts de map (defineChart) : ce qu'ils changent, budgets, replays.

Un script de map (defineChart) anime le playfield d'une map pendant qu'elle se joue, comme les modcharts d'ITG : le playfield se déplace, tourne, change d'échelle, ses colonnes se décalent, ses couleurs et ses images changent au rythme de la chanson. Le script appartient à la map : il n'y a aucun réglage global de mouvement, et une map sans script garde le playfield du skin. Les scripts de map sont distincts des mods, que le joueur installe pour toutes ses parties, et des skins, qui décrivent l'apparence de base du playfield.

Tout est visuel : le jugement et le score ne dépendent jamais du script ; le replay n'enregistre que son empreinte (voir Replays).

Fichiers

Dans le dossier d'une map, à côté de ses charts et de son audio :

  • script.ts sert à toutes les difficultés du dossier ;
  • <nom du fichier du chart>.script.ts ne sert qu'à cette difficulté et l'emporte sur script.ts : Artiste - Titre [Hard].script.ts à côté de Artiste - Titre [Hard].osu.

Le fichier est relu à chaque lancement de la map : on le modifie sans recompiler ni redémarrer le jeu. Il fait au plus 1 Mio.

Le joueur peut désactiver tous les scripts de map : Réglages → Jeu → Autoriser les scripts de map (réglage allowMapScripts, activé par défaut, appliqué côté Rust). Désactivé, aucun script n'est lu et la partie se joue avec le skin seul.

Déclaration

export default defineChart({
  apiVersion: 2,
  // Images du dossier de la map que les gestionnaires affichent pendant la
  // partie (64 au plus), en plus de celles que `setup` nomme.
  images: ["arrows/flash.png"],
  setup(play: PlayContext) {
    playfield.set({ zoom: 0.9 });
    ctx.on("game.beat", (beat: Beat) => { /* … */ });
  },
});

defineChart s'appelle une seule fois, au premier niveau du fichier, avec apiVersion, setup et, au besoin, images et permissions (["stage"], voir plus bas). Un script de map n'a pas d'identifiant : il est celui de sa map. setup(play) reçoit le même PlayContext que le setup d'un skin (mode, disposition, nombre de colonnes, taille de la fenêtre, chanson, paliers de jugement) ; nommez son paramètre play, pas ctx, qui masquerait ctx.on.

Le script tourne dans le même moteur Rust-TS que le skin et les mods, sur le thread mods, chargé à neuf à chaque lancement, après le skin. Il dispose de :

  • playfield.set, playfield.update, lanes.set : les appels des skins, mêmes champs, mêmes bornes, mêmes transitions (voir skins) ;
  • game.song() (avec timing, les points de tempo), game.notes(...), game.player(), game.judgements(), game.playfield(), game.settings(), et log.info / log.warn ;
  • les événements game.songStart, game.beat, game.tick, game.judgement, game.pause, game.resume, game.songEnd, game.playfieldChange, game.settingsChange.

Il n'a ni HUD (hud.* lève une erreur), ni stockage (storage.* lève une erreur), ni réseau, ni accès aux fichiers autres que les images de son dossier, chargées avant la partie.

Avec permissions: ["stage"], il dessine aussi dans le rendu natif (stage.*, voir modding) : ses éléments placés sur le playfield suivent ses déplacements, rotations et zooms. Ses sprites et émetteurs ne nomment que des images déclarées dans defineChart({ images }). Sans la permission, chaque appel stage.* lève une erreur. Le réglage « Autoriser les scripts de map » coupe aussi ce dessin, puisque le script n'est alors pas lu.

Ce que le script change

Le script décrit une couche du playfield, dessinée au-dessus de celle du skin : chaque valeur qu'il donne l'emporte sur celle du skin tant qu'il la donne ; les autres restent celles du skin. Pour une colonne, la chaîne est : la colonne du script (lanes[i]), ses valeurs communes, la colonne du skin, les valeurs communes du skin, puis l'apparence par défaut du moteur. Une largeur de colonne (laneWidth) donnée par le script l'emporte sur la largeur totale (width) du skin.

Tous les champs du playfield sont disponibles (positions, tailles, couleurs, images), et trois servent surtout aux effets :

Champ Bornes Défaut Rôle
rotation −3600 à 3600 0 rotation du playfield entier autour du centre de la boîte des colonnes, en degrés dans le sens horaire ; les notes suivent leurs colonnes
zoom 0 à 4 1 échelle du playfield entier autour du même centre
lanes[i].offsetX −2 à 2 0 décalage horizontal d'une colonne (fond, récepteur, notes, holds), en hauteurs d'écran, vers la droite si positif

Ces trois champs existent aussi pour les skins. Le rendu les applique dans le shader : chaque quad tourne autour de son propre centre, placé là où la rotation et l'échelle du playfield l'amènent. L'entité game.playfield() des mods reste la disposition du lancement (décalages des colonnes compris, sans rotation ni zoom).

Transitions

playfield.update({ patch, transitionMs, easing }) et lanes.set({ column, lane, transitionMs, easing }) changent des valeurs immédiatement ou en transition (0 à 10 000 ms, linear, easeIn, easeOut, easeInOut). Le thread de rendu anime chaque valeur séparément d'après l'horloge de la chanson : un flash de couleur de 100 ms ne coupe pas un balancement de 2 s commencé avant, qu'il vienne du script ou du skin. Une pause arrête les transitions.

Les changements d'un même gestionnaire (un pas du thread mods) qui ont la même transition sont fusionnés ; ceux qui ont des transitions différentes restent séparés et s'appliquent dans l'ordre. On peut donc allumer une couleur puis la faire revenir dans le même gestionnaire :

playfield.update({ patch: { laneColor: "#8a5cf6" } });
playfield.update({ patch: { laneColor: "#454d61" }, transitionMs: 200, easing: "easeOut" });

Tempo et temps

  • play.song.timing (et game.song()?.timing) : les points de tempo du chart, triés, { timeUs, bpm, beatUs, meter } (1024 au plus).
  • game.beat : { index, timeUs, bpm, meterBeat } à chaque temps, à son heure exacte sur l'horloge de la chanson (pas arrondi aux ticks) ; index compte les temps depuis le premier point de tempo, meterBeat vaut 0 sur le premier temps de la mesure. Les temps n'arrivent que pendant que l'horloge avance ; après une pause, le suivant reprend.
  • game.tick : l'heure de la chanson, à la cadence des ticks du thread mods (30 Hz).

Un script efficace démarre des transitions sur les temps et laisse le rendu les animer : il ne s'exécute que quelques fois par seconde.

Images

Les images d'un script sont des PNG de son dossier : celles que setup nomme et celles que defineChart({ images }) déclare. Toutes sont décodées avant la partie, avec les limites des skins (4 Mio, 2048 px de côté, réduites à 640 px, 256 images au plus pour la couche) ; une image déclarée absente ou qui n'est pas un PNG fait échouer le script. Pendant la partie, un changement qui nomme une image non chargée lève une erreur dans le script. Une image du script qui ne tient pas dans l'atlas laisse place à celle du skin.

Budgets et erreurs

Le script est traité comme un mod téléchargé : 30 ms d'exécution par appel (chargement, setup, chaque gestionnaire), 16 Mio de mémoire QuickJS, désactivé après 3 dépassements du budget ou 3 erreurs de suite.

  • Script illisible, defineChart absent ou invalide, apiVersion différente, image déclarée manquante, setup en erreur ou hors budget : l'erreur est signalée (bandeau au lancement, journal des mods) et la partie se joue avec le skin seul.
  • Script désactivé pendant la partie : sa couche disparaît, le playfield du skin reste.
  • Valeur hors bornes ou invalide : ramenée dans les bornes ou remplacée par la valeur du skin, et signalée, comme pour un skin.
  • Sans réponse du thread mods en 1 s, la partie démarre avec l'apparence par défaut du moteur et sans script.

Replays

Le replay enregistre mapScript : l'empreinte BLAKE3 (hexadécimale) de la source du script qui a dessiné la partie, null sans script ou quand il n'a pas pu démarrer. Le jugement ne dépend pas du script : un replay se rejoue à l'identique avec ou sans lui.

Chargement sans ralentir le jeu

lancement de map (thread principal)
  └─ contexte de la partie ; chart autorisé si le réglage le permet
  └─ thread d'aide :
       script de la map : <chart>.script.ts, sinon script.ts
       deux files de changements : skin et script (skin::patch_channel)
       prepare_play(contexte, file du skin, script + sa file)
         ──> thread mods : skin.ts rechargé, setup(play), puis le script
             rechargé, setup(play) ; réponse : les deux PlayfieldSpec
       skin::prepare(couche du skin, couche du script, images stage des mods) :
         validation, décodage PNG, 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 des deux couches,
                  transitions, dessin (rien d'alloué sans changement)

Exemple

examples/map-scripts/first-light/script.ts : le playfield se balance d'un côté à l'autre en s'inclinant, un balancement toutes les deux mesures, et les colonnes s'allument à chaque temps, plus fort sur le premier de la mesure. Copiez-le à côté d'un chart sous le nom script.ts pour l'essayer.

ctx.on("game.beat", (beat: Beat) => {
  const beatMs = 60000 / beat.bpm;
  const downbeat = beat.meterBeat === 0;
  if (downbeat && downbeats++ % SWING_MEASURES === 0) {
    side = -side;
    const swingMs = SWING_MEASURES * meterAt(play.song.timing, beat.timeUs) * beatMs;
    playfield.update({
      patch: { x: 0.5 + side * SWAY, rotation: side * LEAN },
      transitionMs: Math.min(10000, swingMs),
      easing: "easeInOut",
    });
  }
  // A glow at once, then back by the next beat: two paces, two transitions.
  playfield.update({ patch: { laneColor: downbeat ? DOWNBEAT : BEAT } });
  playfield.update({ patch: { laneColor: LANE }, transitionMs: 0.9 * beatMs, easing: "easeIn" });
});

prism.exe --bench-gameplay 10 --map <chemin du .osu> exécute le script de la map comme une vraie partie et affiche PRISM_BENCH map script: … (chemin, état, empreinte, problèmes) ; prism.exe --smoke-test fait de même (PRISM_SMOKE map script: …) pour la map sélectionnée au démarrage.

Source dans le dépôt du jeu : docs/map-scripts.md