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.tssert à toutes les difficultés du dossier ;<nom du fichier du chart>.script.tsne sert qu'à cette difficulté et l'emporte surscript.ts:Artiste - Titre [Hard].script.tsà côté deArtiste - 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()(avectiming, les points de tempo),game.notes(...),game.player(),game.judgements(),game.playfield(),game.settings(), etlog.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(etgame.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) ;indexcompte les temps depuis le premier point de tempo,meterBeatvaut 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 threadmods(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,
defineChartabsent ou invalide,apiVersiondifférente, image déclarée manquante,setupen 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
modsen 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.