-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Mikel
committed
Jun 11, 2024
1 parent
728f643
commit 73faf18
Showing
17 changed files
with
921 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -124,3 +124,6 @@ lib/irrlichtmt | |
|
||
# Generated mod storage database | ||
client/mod_storage.sqlite | ||
|
||
## Craftium | ||
__pycache__ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
# Craftium | ||
|
||
Craftium is a fully open-source research platform for Reinforcement Learning (RL) research. Craftium provides a [Gymnasium](https://gymnasium.farama.org/index.html) wrapper for the [Minetest](https://www.minetest.net/) voxel game engine. | ||
|
||
## Commands | ||
|
||
* `mkdocs new [dir-name]` - Create a new project. | ||
* `mkdocs serve` - Start the live-reloading docs server. | ||
* `mkdocs build` - Build the documentation site. | ||
* `mkdocs -h` - Print help message and exit. | ||
|
||
## Project layout | ||
|
||
mkdocs.yml # The configuration file. | ||
docs/ | ||
index.md # The documentation homepage. | ||
... # Other markdown pages, images and other files. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
site_name: Craftium | ||
|
||
nav: | ||
- Home: index.md | ||
|
||
theme: readthedocs |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .env import CraftiumEnv |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
import os | ||
from typing import Optional | ||
import time | ||
|
||
from .mt_client import MtClient | ||
from .minetest import Minetest | ||
|
||
import numpy as np | ||
|
||
# import gymnasium as gym | ||
from gymnasium import Env | ||
from gymnasium.spaces import Dict, Discrete, Box | ||
|
||
|
||
class CraftiumEnv(Env): | ||
metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 30} | ||
|
||
def __init__( | ||
self, | ||
obs_width: int = 640, | ||
obs_height: int = 360, | ||
init_frames: int = 15, | ||
render_mode: Optional[str] = None, | ||
max_timesteps: Optional[int] = None, | ||
run_dir: Optional[os.PathLike] = None, | ||
): | ||
super(CraftiumEnv, self).__init__() | ||
|
||
self.obs_width = obs_width | ||
self.obs_height = obs_height | ||
self.init_frames = init_frames | ||
self.max_timesteps = max_timesteps | ||
|
||
self.action_space = Dict({ | ||
"forward": Discrete(2), | ||
"backward": Discrete(2), | ||
"left": Discrete(2), | ||
"right": Discrete(2), | ||
"jump": Discrete(2), | ||
"aux1": Discrete(2), | ||
"sneak": Discrete(2), | ||
"zoom": Discrete(2), | ||
"dig": Discrete(2), | ||
"place": Discrete(2), | ||
"drop": Discrete(2), | ||
"inventory": Discrete(2), | ||
"slot_1": Discrete(2), | ||
"slot_2": Discrete(2), | ||
"slot_3": Discrete(2), | ||
"slot_4": Discrete(2), | ||
"slot_5": Discrete(2), | ||
"slot_6": Discrete(2), | ||
"slot_7": Discrete(2), | ||
"slot_8": Discrete(2), | ||
"slot_9": Discrete(2), | ||
"mouse": Box(low=-1, high=1, shape=(2,), dtype=np.float32), | ||
}) | ||
|
||
# names of the actions in the order they must be sent to MT | ||
self.action_order = [ | ||
"forward", "backward", "left", "right", "jump", "aux1", "sneak", | ||
"zoom", "dig", "place", "drop", "inventory", "slot_1", "slot_2", | ||
"slot_3", "slot_4", "slot_5", "slot_6", "slot_7", "slot_8", "slot_9", | ||
] | ||
|
||
self.observation_space = Box(low=0, high=255, shape=(obs_width, obs_height, 3)) | ||
|
||
assert render_mode is None or render_mode in self.metadata["render_modes"] | ||
self.render_mode = render_mode | ||
|
||
# handles the MT configuration and process | ||
self.mt = Minetest( | ||
run_dir=run_dir, | ||
headless=render_mode != "human", | ||
) | ||
|
||
# variable initialized in the `reset` method | ||
self.client = None # client that connects to minetest | ||
|
||
self.last_observation = None # used in render if "rgb_array" | ||
self.timesteps = 0 # the timesteps counter | ||
|
||
def _get_info(self): | ||
return dict() | ||
|
||
def reset( | ||
self, | ||
*, | ||
seed: Optional[int] = None, | ||
options: Optional[dict] = None, | ||
): | ||
super().reset(seed=seed) | ||
self.timesteps = 0 | ||
|
||
# kill the active mt process and the python client if any | ||
if self.client is not None: | ||
self.client.close() | ||
self.mt.kill_process() | ||
|
||
# start the new MT process | ||
self.mt.start_process() # launch the new MT process | ||
time.sleep(2) # wait for MT to initialize (TODO Improve this) | ||
|
||
# connect the client to the MT process | ||
self.client = MtClient( | ||
img_width=self.obs_width, | ||
img_height=self.obs_height, | ||
) | ||
|
||
# HACK skip some frames to let the game initialize | ||
for _ in range(self.init_frames): | ||
_observation, _reward = self.client.receive() | ||
self.client.send([0]*21, 0, 0) # nop action | ||
|
||
observation, _reward = self.client.receive() | ||
self.last_observation = observation | ||
|
||
info = self._get_info() | ||
|
||
return observation, info | ||
|
||
def step(self, action): | ||
self.timesteps += 1 | ||
|
||
# convert the action dict to a format to be sent to MT through mt_client | ||
keys = [0]*21 # all commands (keys) except the mouse | ||
mouse_x, mouse_y = 0, 0 | ||
for k, v in action.items(): | ||
if k == "mouse": | ||
x, y = v[0], v[1] | ||
mouse_x = int(x*(self.obs_width // 2)) | ||
mouse_y = int(y*(self.obs_height // 2)) | ||
else: | ||
keys[self.action_order.index(k)] = v | ||
# send the action to MT | ||
self.client.send(keys, mouse_x, mouse_y) | ||
|
||
# receive the new info from minetest | ||
observation, reward = self.client.receive() | ||
self.last_observation = observation | ||
|
||
info = self._get_info() | ||
|
||
# TODO Get the real termination info | ||
terminated = False | ||
truncated = self.max_timesteps is not None and self.timesteps >= self.max_timesteps | ||
|
||
return observation, reward, terminated, truncated, info | ||
|
||
def render(self): | ||
if self.render_mode == "rgb_array": | ||
return self.last_observation | ||
|
||
def close(self): | ||
self.mt.kill_process() | ||
self.mt.clear() | ||
self.client.close() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
import os | ||
from typing import Optional, Any | ||
import subprocess | ||
import multiprocessing | ||
from uuid import uuid4 | ||
import shutil | ||
from distutils.dir_util import copy_tree | ||
|
||
|
||
def launch_process(cmd: str, cwd: Optional[os.PathLike] = None): | ||
def launch_fn(): | ||
stderr = open(os.path.join(cwd, "stderr.txt"), "w") | ||
stdout = open(os.path.join(cwd, "stdout.txt"), "w") | ||
subprocess.run(cmd, cwd=cwd, stderr=stderr, stdout=stdout) | ||
process = multiprocessing.Process(target=launch_fn, args=[]) | ||
process.start() | ||
return process | ||
|
||
|
||
class Minetest(): | ||
def __init__( | ||
self, | ||
run_dir: Optional[os.PathLike] = None, | ||
run_dir_prefix: Optional[os.PathLike] = None, | ||
headless: bool = False, | ||
seed: Optional[int] = None, | ||
): | ||
# create a dedicated directory for this run | ||
if run_dir is None: | ||
self.run_dir = f"./minetest-run-{uuid4()}" | ||
if run_dir_prefix is not None: | ||
self.run_dir = os.path.join(run_dir_prefix, self.run_dir) | ||
else: | ||
self.run_dir = run_dir | ||
# delete the directory if it already exists | ||
if os.path.exists(self.run_dir): | ||
shutil.rmtree(self.run_dir) | ||
# create the directory | ||
os.mkdir(self.run_dir) | ||
|
||
print(f"==> Creating Minetest run directory: {self.run_dir}") | ||
|
||
config = dict( | ||
# Base config | ||
enable_sound=False, | ||
show_debug=False, | ||
enable_client_modding=True, | ||
csm_restriction_flags=0, | ||
enable_mod_channels=True, | ||
screen_w=640, | ||
screen_h=360, | ||
vsync=False, | ||
fps_max=1000, | ||
fps_max_unfocused=1000, | ||
undersampling=1000, | ||
# fov=self.fov_y, | ||
# game_dir=self.game_dir, | ||
|
||
# Adapt HUD size to display size, based on (1024, 600) default | ||
# hud_scaling=self.display_size[0] / 1024, | ||
|
||
# Attempt to improve performance. Impact unclear. | ||
server_map_save_interval=1000000, | ||
profiler_print_interval=0, | ||
active_block_range=2, | ||
abm_time_budget=0.01, | ||
abm_interval=0.1, | ||
active_block_mgmt_interval=4.0, | ||
server_unload_unused_data_timeout=1000000, | ||
client_unload_unused_data_timeout=1000000, | ||
full_block_send_enable_min_time_from_building=0.0, | ||
max_block_send_distance=100, | ||
max_block_generate_distance=100, | ||
num_emerge_threads=0, | ||
emergequeue_limit_total=1000000, | ||
emergequeue_limit_diskonly=1000000, | ||
emergequeue_limit_generate=1000000, | ||
) | ||
if seed is not None: | ||
config["fixed_map_seed"] = seed | ||
|
||
self._write_config(config, os.path.join(self.run_dir, "minetest.conf")) | ||
|
||
# get the path location of the parent of this module (where all the minetest stuff is located) | ||
root_path = os.path.dirname(os.path.dirname(__file__)) | ||
|
||
# create the directory tree structure needed by minetest | ||
self._create_mt_dirs(root_dir=root_path, target_dir=self.run_dir) | ||
|
||
self.launch_cmd = ["./bin/minetest", "--go"] | ||
|
||
# set the env. variables to execute mintest in headless mode | ||
if headless: | ||
os.environ["SDL_VIDEODRIVER"] = "offscreen" | ||
|
||
self.proc = None | ||
|
||
def start_process(self): | ||
self.proc = launch_process(self.launch_cmd, self.run_dir) | ||
|
||
def kill_process(self): | ||
self.proc.terminate() | ||
|
||
def clear(self): | ||
# delete the run's directory | ||
if os.path.exists(self.run_dir): | ||
shutil.rmtree(self.run_dir) | ||
|
||
def _write_config(self, config: dict[str, Any], path: os.PathLike): | ||
with open(path, "w") as f: | ||
for key, value in config.items(): | ||
f.write(f"{key} = {value}\n") | ||
|
||
def _create_mt_dirs(self, root_dir: os.PathLike, target_dir: os.PathLike): | ||
def link_dir(name): | ||
os.symlink(os.path.join(root_dir, name), | ||
os.path.join(target_dir, name)) | ||
def copy_dir(name): | ||
copy_tree(os.path.join(root_dir, name), | ||
os.path.join(target_dir, name)) | ||
|
||
link_dir("builtin") | ||
link_dir("fonts") | ||
link_dir("locale") | ||
link_dir("textures") | ||
link_dir("bin") | ||
|
||
copy_dir("worlds") | ||
copy_dir("games") | ||
copy_dir("client") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
import socket | ||
import struct | ||
|
||
import numpy as np | ||
|
||
MT_IP = "127.0.0.1" | ||
MT_PORT = 4343 | ||
|
||
class MtClient(): | ||
def __init__(self, img_width: int, img_height: int): | ||
self.img_width = img_width | ||
self.img_height = img_height | ||
|
||
self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||
self.s.connect((MT_IP, MT_PORT)) | ||
|
||
# pre-compute the number of bytes that we should receive from MT | ||
self.rec_bytes = img_width*img_height*3 + 8 # the RGB image + 8 bytes of the reward | ||
|
||
def receive(self): | ||
data = [] | ||
while len(data) < self.rec_bytes: | ||
data += self.s.recv(self.rec_bytes) | ||
|
||
# decode the reward value | ||
reward_bytes = bytes(data[-8:]) # the last 8 bytes | ||
# uncpack the double (float in python) in native endianess | ||
reward = struct.unpack("d", bytes(reward_bytes))[0] | ||
|
||
# decode the observation RGB image | ||
data = data[:-8] # get the image data, all bytes except the last 8 | ||
# reshape received bytes into an image | ||
img = np.fromiter( | ||
data, | ||
dtype=np.uint8, | ||
count=(self.rec_bytes-8) | ||
).reshape(self.img_width, self.img_height, 3) | ||
|
||
return img, reward | ||
|
||
def send(self, keys: list[int], mouse_x: int, mouse_y: int): | ||
assert len(keys) == 21, f"Keys list must be of length 21 and is {len(keys)}" | ||
|
||
mouse = list(struct.pack("<h", mouse_x)) + list(struct.pack("<h", mouse_y)) | ||
|
||
self.s.sendall(bytes(keys + mouse)) | ||
|
||
def close(self): | ||
self.s.close() |
Oops, something went wrong.