Comment fonctionne mon bot qui poste des paysages Minecraft aléatoires ?
Qu'est-ce que c'est ?
J'ai créé le bot https://3615.computer/@CraftViews qui envoie toutes les 4h une "photo" d'un paysage Minecraft entièrement alé
Exemples
Idée
J'ai toujours trouvé les paysages de Minecraft absolument fantastique. Généré aléatoirement et procéduralement, ils sont toujours uniques et peuvent être d'une incroyable beauté.
Ajoutant à cela des shaders, permettant de rendre le jeu encore plus beau, on obtient des vues magnifiques. Ajoutez à ça quelques éléments tel que de l'eau, une source de lave brillante de mille feux, un lever ou coucher de soleil et on a le plus beau des paysages.
Pour nous vieux joueurs (je joue depuis la version Alpha du jeu en décembre 2010), il y a aussi un côté nostalgique à voir ces paysages. À la façon de Edward Hopper, souvent vides de vie, ils ont quelque chose d'un peu étrange, comme si c'était abandonné de toute vie.
Dans les grandes lignes
J'avais ce projet en tête depuis un moment mais aucune idée de comment le faire. Je pensais que j'allais devoir écrire un plugin Minecraft, me semblant impossible à faire vu mes compétences en Java.
C'est finalement ChatGPT qui m'a mis sur une piste intéressante: pourquoi ne pas plutôt faire un script qui contrôle le client Minecraft ?
En effet, suffit de lancer le programme, et à coup de wait en magic numbers (n'ayant pas de retour de ce que le programme fait) et de commandes in-game, on peut se débrouiller pour faire quelque chose !
Donc me voilà avec un script en Go qui fait globalement ça:
main.go
:
package main
import (
"fmt"
"github.com/charmbracelet/log"
"github.com/joho/godotenv"
)
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
launchGame()
createNewWorld()
setupScreenshot()
for i := 0; i < SHOTS; i++ {
log.Info(fmt.Sprintf("** Starting a new shot (%d/%d) **", i, SHOTS))
setRandomTime()
setRandomWeather()
teleportPlayer()
takeRandomScreenshot()
path := getLatestScreenshot()
postScreenshotToSocialMedia(path, i) // TODO: Run in background
}
quitGame()
cleanup() // TODO
}
minecraft.go
:
package main
import (
"fmt"
"io/fs"
"math/rand"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/charmbracelet/log"
"github.com/go-vgo/robotgo"
)
const (
SHOTS = 24
WAIT_GAME_LAUNCH = 15
WAIT_GENERATION = 15
WAIT_CHUNKS_LOADING = 45
)
type PlayerRot struct {
rx string
rz string
}
func launchGame() {
log.Info("Starting Minecraft...")
cmd := exec.Command([extremely long command to launch Minecraft client directly without using the launcher...])
if err := cmd.Start(); err != nil {
log.Fatalf("Failed to start Minecraft: %s", err)
}
// Wait for the game to launch
log.Info("Game launching", "sleep", WAIT_GAME_LAUNCH)
time.Sleep(WAIT_GAME_LAUNCH * time.Second)
}
func createNewWorld() {
// Navigate the menu to create a new world
robotgo.KeySleep = 350
robotgo.KeyTap("down") // Singleplayer
robotgo.KeyTap("enter") // Enter singleplayer
robotgo.KeyTap("tab")
robotgo.KeyTap("tab")
robotgo.KeyTap("tab") // Create new world
robotgo.KeyTap("enter") // Select
robotgo.KeyTap("a", "cmd")
robotgo.KeyTap("backspace")
robotgo.TypeStr("BOT_SCREENSHOT")
robotgo.KeyTap("tab")
robotgo.KeyTap("space") // Turn hardcore mode
robotgo.KeyTap("space") // Turn creative mode
robotgo.KeyTap("tab")
robotgo.KeyTap("tab")
robotgo.KeyTap("tab")
robotgo.KeyTap("enter") // Create new world
// Wait for world generation to complete
log.Info("Generating world", "sleep", WAIT_GENERATION)
time.Sleep(WAIT_GENERATION * time.Second)
}
func setupScreenshot() {
time.Sleep(1)
runMinecraftChatCommand("/gamemode spectator")
time.Sleep(1)
robotgo.KeyTap("f1") // Hide HUD
}
func setRandomTime() {
dayTime := getRandomTime()
log.Info(fmt.Sprintf("Time set to %s", dayTime))
runMinecraftChatCommand(fmt.Sprintf("/time set %s", dayTime))
}
func setRandomWeather() {
// Set weather to clear by default
weather := "clear"
weatherRand := rand.Float64()
if weatherRand <= 0.1 { // 10% chance of rain or thunder
rainy := []string{"rain", "thunder"}
weather = rainy[rand.Intn(len(rainy))] // 50% chance of rain or thunder
}
log.Info(fmt.Sprintf("Weather set to %s", weather))
runMinecraftChatCommand(fmt.Sprintf("/weather %s", weather))
}
func teleportPlayer() {
// Teleport the player to random surface location in a 20,000×20,000-block area centered on (0,0)
runMinecraftChatCommand("/spreadplayers 0 0 0 10000 true @p")
time.Sleep(2 * time.Second) // Wait for the TP to happen
// Set player's camera to random rotation
rot := getRandomAngle()
log.Info(fmt.Sprintf("Random rotation: 'RX: %s, RZ: %s'", rot.rx, rot.rz))
runMinecraftChatCommand(fmt.Sprintf("/tp @p ~ ~ ~ %s %s", rot.rx, rot.rz))
// Teleport player 8 blocks above max
runMinecraftChatCommand(fmt.Sprintf("/tp @p ~ ~%d ~", rand.Intn(9)))
log.Info("Loading chunks", "sleep", WAIT_CHUNKS_LOADING)
time.Sleep((WAIT_CHUNKS_LOADING * time.Second)) // Wait for the chunks generation and rendering
}
func takeRandomScreenshot() {
// Take a screenshot
log.Info("Taking screenshot by pressing F2")
robotgo.KeyTap("f2") // Take native screenshot
time.Sleep(2 * time.Second) // Save screenshot
}
func quitGame() {
// Close the game
robotgo.KeyTap("q", "cmd")
}
func getLatestScreenshot() string {
fileInfo, err := getLastCreatedFile()
if err != nil {
log.Fatal(err)
}
if fileInfo != nil {
log.Info(fmt.Sprintf("Screenshot found: %s, created at %s\n", fileInfo.Name(), fileInfo.ModTime()))
} else {
log.Fatal("Screenshot not found.")
}
return filepath.Join(os.Getenv("SCREENSHOTS_DIR_PATH"), fileInfo.Name())
}
// getLastCreatedFile takes a directory path as an argument and returns the FileInfo
// of the most recently created file in that directory. If the directory is empty,
// or if there are no files in the directory, it returns nil.
func getLastCreatedFile() (fs.FileInfo, error) {
dir := os.Getenv("SCREENSHOTS_DIR_PATH")
// Initialize variables to store information about the newest file
var newestFile fs.FileInfo
var newestTime time.Time
// Walk through the directory and its subdirectories
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
// If an error occurs, return the error and stop walking the directory
if err != nil {
return err
}
// If the current entry is a directory, skip it
if d.IsDir() {
return nil
}
// Retrieve the FileInfo of the current file
info, err := d.Info()
if err != nil {
return err
}
// Check if the current file is newer than the newest file found so far
if info.ModTime().After(newestTime) {
// If it is, update newestFile and newestTime with the current file's information
newestFile = info
newestTime = info.ModTime()
}
// Continue walking through the directory
return nil
})
// If an error occurred during the directory walk, return the error
if err != nil {
return nil, err
}
// Return the FileInfo of the newest file found (or nil if no files were found)
return newestFile, nil
}
func runMinecraftChatCommand(cmd string) {
robotgo.KeyTap("t") // Enter chat
robotgo.TypeStr(cmd)
robotgo.KeyTap("enter") // Run command
}
func getRandomAngle() PlayerRot {
return PlayerRot{
rx: fmt.Sprint(rand.Intn(361)),
rz: fmt.Sprint(rand.Intn(41) - 20), // Keep between -20 and +20
}
}
func getRandomTime() string {
// https://minecraft.fandom.com/fr/wiki/Cycle_jour-nuit#P%C3%A9riodes_d'un_cycle
return fmt.Sprint(rand.Intn(450) + rand.Intn(13805))
}
Comment tout ça fonctionne
- On lance le jeu Minecraft, en utilisant les tokens d'authentication et autres contenus dans le fichier .env
- On attend 15 secondes que le jeu se lance, et on commence à se déplacer dans les menus pour créer une nouvelle partie, nouveau monde, en créatif
- On attend de nouveau 15 secondes que la partie se lance
- On se met en mode spectator, afin de pouvoir léviter
- On appuie sur F1 pour cacher le HUD
- Selon des critères (aléatoire avec un poids) on sélectionne un time (on priviligie le jour) ainsi qu'une météo 10% de pluie ou d'orange, puis 1 chance sur deux entre les deux.
Comment téléporter le joueur à la surface
Ensuite vient la fonctionne de téléportation du joueur.
J'avais commencé par faire un script assez complexe, le déplaçant en XYZ de manière random, et enfin chercher un endroit où il avait X blocs libres au dessus de lui. Problème: c'était lent, très lent, et parfois il était simplement coincé dans une grotte sans vue.
Et là j'ai découvert la commande /spreadplayers 0 0 0 10000 true @p
Voici ce que la documentation indique:
Dans mon exemple ça donne /spreadplayers 0 0 0 10000 true @p
- Depuis le centre de la map (0 0), on laisse la distance entre les jours à 0 (étant seul) on place le joueur quelque part autour de 10000 blocs de centre, regroupement par équipe on s'en fiche, étant seul, et on sélectionne le joueur actuelle (@p).
En gros, on téléporte le joueur de manière aléatoire sur une surface, dans un champ de 10000 blocs autour de XY 00.
Cette commande permet de s'éviter bien des soucis de se coincer dans des murs. Malheureusement, elle ne permet de nous placer dans des grandes cavernes, qui sont parfois magnifiques, mais peut-être qu'on trouve moyen de faire ça plus tard.
Il ne reste plus qu'à tourner légèrement la tête du joueur, histoire qu'il ne regarde pas toujours le même endroit, ni droit sur l'horizon, dans une valeur limité afin qu'il ne regarde pas ses pieds, et on le téléporte jusqu'à 8 blocs au dessus, pour qu'il ait parfois une vue dégagée.
Ensuite c'est assez simple: on appuie sur "F2" pour prendre le screenshot, on va chercher le plus récent, et on programme le statut, un tous les 4h.
On répète l'opération 25 fois. Pourquoi 25 ? Car j'upload directement sur son compte Mastodon en tant que Scheduled Posts et l'API ne permet pas de stocker plus de 25 posts.
Une fois le tout terminé, on quitte le jeu, et c'est fini.
Qu'est-ce qu'on peut améliorer
Automatisation
Eh bien grande surprise, c'est moi-même qui lance ce script tous les 4 jours. En effet, je n'ai pas trouvé de moyen encore d'automatiser ça dans le cloud.
J'ai pourtant l'idée en tête:
Lancer une instance fly.io avec GPU une fois dispo ou runpod.io avec un GPU de gamer. Lancer une image Docker qui contient tout ce qu'il faut, et qui sait se servir du GPU. Lancer le script, éteindre la machine, et refaire ça régulièrement.
Je n'ai pas chercher longtemps à faire fonctionner cela, je devrais commencer sur mon PC fixe Windows et voir ce que ça donne avant d'essayer de mettre ça dans le cloud. Un jour peut-être.
Bulking screenshots
Pourquoi s'arrêter au nombre de scheduled posts permis par Mastodon ? On pourrait très bien créer 1000 screenshots, les stockers quelque part, et ensuite un script s'occupe de faire le travail
- Récupérer un screenshot non envoyé, via une DB type SQLite3
- La publier directement
- Archiver l'image du screenshot si ça s'est envoyé avec succès
Ça découple la prise de screenshots et l'envoie des screenshots, et ça permet même de facilement partager ces screenshots sur d'autres réseaux.
Random shaders, resource packs, map generators
Il existe de bien nombreux shaders, resource packs et de biome generator à tester. Les combiner ensemble peuvent donner des résultats suprenants, qu'on pourrait intégrer à notre système. Ça ne devrait pas être compliqué si les menus pour le faire sont accessible via le clavier.
Partager le seed de la map ainsi que les coordonnées
Via le client je ne peux malheureusement pas le faire, en effet même si mon script Go peut générer le seed, je ne peux pas l'input dans le jeu. Pourquoi ? Eh bien parce que Minecraft ne permet pas d'accéder au menu d'input du seed via le clavier. Je pourrais éventuellement voir via la souris mais alors là ça devient plus galère.
Par contre ! Je peux lancer un serveur Minecraft avec toutes les inputs que je veux, dont le seed, et simplement dire au client de s'y connecter. Ça rendrait les choses plus simples. Je pourrais alors donner le seed, la position, l'heure et la météo.
Écrire un alt text sur les screenshots
Alors oui on déteste les IA blablabla mais demander à ChatGPT de décrire ce qu'il voit permettrait de rendre accessible les images postées, et c'est important. La dernière fois que j'ai regardé, leur API ne le permettait pas encore mais peut-être que maintenant oui.
Coût
Bon si on arrive à tout automatiser, fly.io / runpod.io / ChatGPT / stockage des screenshots on va quand même arriver à un petit coût. Il faudrait que le projet se populaire pour être financer mais c'est pas vraiment un problème maintenant.
Mais n'hésitez pas à reboost vos préférés et en faire la promotion directement, ça m'encourage et ça pourrait m'aider à le financer (pas me verser de l'argent, simplement financer le bot)
Le code est open-source
It's not much but it's honest work, il vous faudra bidouiller quelques trucs, notamment le chemin de lancement de Minecraft.
Pour se faire, lancer le normalement via le launcher, et regardez (sur Linux/MacOS) la commande dans votre terminal via ps aux
. Copiez la, mettez les valeurs sensibles dans le .env et voilà.
Petite astuce, afin de gagner en rapidité d'éxecution, j'utilise ce modpack https://www.curseforge.com/minecraft/modpacks/boosted-fps qui permet de gagner énormément de FPS et un gain de génération des chunks. N'oubliez pas cependant d'ajouter les shaders, et régler quelques paramètres graphiques pour qu'ils ne soient pas au plus bas.