Comment fonctionne mon bot qui poste des paysages Minecraft aléatoires ?

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

  1. On lance le jeu Minecraft, en utilisant les tokens d'authentication et autres contenus dans le fichier .env
  2. 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
  3. On attend de nouveau 15 secondes que la partie se lance
  4. On se met en mode spectator, afin de pouvoir léviter
  5. On appuie sur F1 pour cacher le HUD
  6. 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:

😍
/spreadplayers <X Z> <Distance entre les joueurs> <Taille de la zone> <Regroupement par équipe> <Joueurs>

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.

GitHub - VictorBersy/minecraft-screenshot-bot: Capture random screenshots from a new world
Capture random screenshots from a new world. Contribute to VictorBersy/minecraft-screenshot-bot development by creating an account on GitHub.

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.