├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── client ├── Actor.gd ├── Actor.tscn ├── Avatar.tscn ├── Chatbox.gd ├── Chatbox.tscn ├── Login.gd ├── Login.tscn ├── Main.gd ├── Main.tscn ├── default_env.tres ├── icon.png ├── icon.png.import ├── model.gd ├── packet.gd ├── project.godot ├── tilemap_packed.png ├── tilemap_packed.png.import └── websockets_client.gd ├── copy-certs.sh └── server ├── __main__.py ├── manage.py ├── models.py ├── packet.py ├── protocol.py └── utils.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Deploy MMO on production server 2 | 3 | # Controls when the action will run. Triggers the workflow on any release updates (the workflow will only (re)deploy the latest tag though) 4 | on: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - v1.* 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | # This workflow contains a single job called "build" 14 | build: 15 | # The type of runner that the job will run on 16 | runs-on: ubuntu-latest 17 | 18 | # Steps represent a sequence of tasks that will be executed as part of the job 19 | steps: 20 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 21 | - uses: actions/checkout@master 22 | 23 | # (Re)deploys the latest release on the remote host 24 | - name: Stop the currently running server 25 | uses: fifsky/ssh-action@master 26 | with: 27 | command: | 28 | tmux kill-session -t gameserver 29 | cd ${{ secrets.REMOTE_GAME_DIR }} 30 | git fetch --all --tags --force 31 | latestTag=$(git describe --tags `git rev-list --tags --max-count=1`) 32 | git checkout $latestTag --force 33 | source ${{ secrets.REMOTE_GAME_DIR }}/server/venv/bin/activate 34 | python3 ${{ secrets.REMOTE_GAME_DIR }}/server/manage.py makemigrations server 35 | python3 ${{ secrets.REMOTE_GAME_DIR }}/server/manage.py migrate 36 | tmux new-session -d -s gameserver \; send-keys "python3 ${{ secrets.REMOTE_GAME_DIR }}/server" Enter 37 | exit 38 | host: ${{ secrets.REMOTE_HOST }} 39 | user: ${{ secrets.REMOTE_USER }} 40 | key: ${{ secrets.SSH_KEY }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | server/__pycache__ 2 | server/venv 3 | server/db.sqlite3 4 | server/migrations 5 | server/certs 6 | 7 | # Godot-specific ignores 8 | client/.import/ 9 | client/export.cfg 10 | client/export_presets.cfg 11 | 12 | # Imported translations (automatically generated from CSV files) 13 | client/*.translation 14 | 15 | # Mono-specific ignores 16 | client/.mono/ 17 | client/data_*/ 18 | 19 | client/HTML5/ 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Godot Python MMO 2 | This project is part of a blog and YouTube tutorial series on creating a multipler online game using Python and Godot. For more information, see https://tbat.me/2022/11/20/godot-python-mmo-part-1.html -------------------------------------------------------------------------------- /client/Actor.gd: -------------------------------------------------------------------------------- 1 | extends "res://model.gd" 2 | 3 | onready var body: KinematicBody2D = get_node("KinematicBody2D") 4 | onready var label: Label = get_node("KinematicBody2D/Label") 5 | onready var sprite: Sprite = get_node("KinematicBody2D/Avatar") 6 | onready var animation_player: AnimationPlayer = get_node("KinematicBody2D/Avatar/AnimationPlayer") 7 | 8 | var server_position: Vector2 9 | var initialised_position: bool = false 10 | var actor_name: String 11 | var velocity: Vector2 = Vector2.ZERO 12 | 13 | var is_player: bool = false 14 | var _player_target: Vector2 15 | 16 | var rubber_band_radius: float = 200 17 | 18 | var speed: float = 70.0 19 | 20 | func _ready(): 21 | update(initial_data) 22 | 23 | func update(new_model: Dictionary): 24 | .update(new_model) 25 | 26 | # Set the correct sprite for the actor's avatar ID 27 | if new_model.has("avatar_id"): 28 | sprite.set_region_rect(Rect2(368, new_model["avatar_id"] * 48, 64, 48)) 29 | 30 | if new_model.has("instanced_entity"): 31 | var ientity = new_model["instanced_entity"] 32 | 33 | if ientity.has("x") and ientity.has("y"): 34 | server_position = Vector2(float(ientity["x"]), float(ientity["y"])) 35 | 36 | if not initialised_position: 37 | initialised_position = true 38 | body.position = server_position 39 | if is_player: 40 | _player_target = server_position 41 | elif (body.position - server_position).length() > rubber_band_radius: 42 | # Rubber band if body position too far away from server position 43 | body.position = server_position 44 | 45 | 46 | if ientity.has("entity"): 47 | var entity = ientity["entity"] 48 | if entity.has("name"): 49 | actor_name = ientity["entity"]["name"] 50 | 51 | if label: 52 | label.text = actor_name 53 | 54 | func _physics_process(delta): 55 | var target: Vector2 56 | if is_player: 57 | target = _player_target 58 | else: 59 | target = server_position 60 | 61 | velocity = (target - body.position).normalized() * speed 62 | if (target - body.position).length() > 5: 63 | velocity = body.move_and_slide(velocity) 64 | else: 65 | velocity = Vector2.ZERO 66 | 67 | func _process(delta): 68 | # Get the direction angle 69 | var angle = velocity.angle() 70 | 71 | # Check which quadrant the angle is in and play animation accordingly 72 | if velocity.length() <= 5: 73 | animation_player.stop() 74 | elif -PI/4 <= angle and angle < PI/4: 75 | animation_player.play("walk_right") 76 | elif PI/4 <= angle and angle < 3*PI/4: 77 | animation_player.play("walk_down") 78 | elif -3*PI/4 <= angle and angle < -PI/4: 79 | animation_player.play("walk_up") 80 | else: 81 | animation_player.play("walk_left") 82 | 83 | 84 | -------------------------------------------------------------------------------- /client/Actor.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=3 format=2] 2 | 3 | [ext_resource path="res://Avatar.tscn" type="PackedScene" id=1] 4 | [ext_resource path="res://Actor.gd" type="Script" id=2] 5 | 6 | [node name="Actor" type="Node2D"] 7 | script = ExtResource( 2 ) 8 | 9 | [node name="KinematicBody2D" type="KinematicBody2D" parent="."] 10 | 11 | [node name="Label" type="Label" parent="KinematicBody2D"] 12 | margin_left = -53.0 13 | margin_top = 46.0 14 | margin_right = 53.0 15 | margin_bottom = 60.0 16 | align = 1 17 | 18 | [node name="Avatar" parent="KinematicBody2D" instance=ExtResource( 1 )] 19 | region_rect = Rect2( 368, 0, 64, 48 ) 20 | -------------------------------------------------------------------------------- /client/Avatar.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=7 format=2] 2 | 3 | [ext_resource path="res://tilemap_packed.png" type="Texture" id=1] 4 | 5 | [sub_resource type="Animation" id=1] 6 | length = 0.001 7 | tracks/0/type = "value" 8 | tracks/0/path = NodePath(".:frame") 9 | tracks/0/interp = 1 10 | tracks/0/loop_wrap = true 11 | tracks/0/imported = false 12 | tracks/0/enabled = true 13 | tracks/0/keys = { 14 | "times": PoolRealArray( 0 ), 15 | "transitions": PoolRealArray( 1 ), 16 | "update": 0, 17 | "values": [ 0 ] 18 | } 19 | 20 | [sub_resource type="Animation" id=3] 21 | resource_name = "walk_down" 22 | length = 0.3 23 | loop = true 24 | tracks/0/type = "value" 25 | tracks/0/path = NodePath(".:frame") 26 | tracks/0/interp = 1 27 | tracks/0/loop_wrap = true 28 | tracks/0/imported = false 29 | tracks/0/enabled = true 30 | tracks/0/keys = { 31 | "times": PoolRealArray( 0, 0.1, 0.2 ), 32 | "transitions": PoolRealArray( 1, 1, 1 ), 33 | "update": 1, 34 | "values": [ 1, 5, 9 ] 35 | } 36 | 37 | [sub_resource type="Animation" id=2] 38 | resource_name = "walk_left" 39 | length = 0.3 40 | loop = true 41 | tracks/0/type = "value" 42 | tracks/0/path = NodePath(".:frame") 43 | tracks/0/interp = 1 44 | tracks/0/loop_wrap = true 45 | tracks/0/imported = false 46 | tracks/0/enabled = true 47 | tracks/0/keys = { 48 | "times": PoolRealArray( 0, 0.1, 0.2 ), 49 | "transitions": PoolRealArray( 1, 1, 1 ), 50 | "update": 1, 51 | "values": [ 0, 4, 8 ] 52 | } 53 | 54 | [sub_resource type="Animation" id=4] 55 | resource_name = "walk_right" 56 | length = 0.3 57 | loop = true 58 | tracks/0/type = "value" 59 | tracks/0/path = NodePath(".:frame") 60 | tracks/0/interp = 1 61 | tracks/0/loop_wrap = true 62 | tracks/0/imported = false 63 | tracks/0/enabled = true 64 | tracks/0/keys = { 65 | "times": PoolRealArray( 0, 0.1, 0.2 ), 66 | "transitions": PoolRealArray( 1, 1, 1 ), 67 | "update": 1, 68 | "values": [ 3, 7, 11 ] 69 | } 70 | 71 | [sub_resource type="Animation" id=5] 72 | resource_name = "walk_up" 73 | length = 0.3 74 | loop = true 75 | tracks/0/type = "value" 76 | tracks/0/path = NodePath(".:frame") 77 | tracks/0/interp = 1 78 | tracks/0/loop_wrap = true 79 | tracks/0/imported = false 80 | tracks/0/enabled = true 81 | tracks/0/keys = { 82 | "times": PoolRealArray( 0, 0.1, 0.2 ), 83 | "transitions": PoolRealArray( 1, 1, 1 ), 84 | "update": 1, 85 | "values": [ 2, 6, 10 ] 86 | } 87 | 88 | [node name="Avatar" type="Sprite"] 89 | scale = Vector2( 4, 4 ) 90 | texture = ExtResource( 1 ) 91 | hframes = 4 92 | vframes = 3 93 | region_enabled = true 94 | region_rect = Rect2( 368, 0, 64, 48 ) 95 | 96 | [node name="AnimationPlayer" type="AnimationPlayer" parent="."] 97 | anims/RESET = SubResource( 1 ) 98 | anims/walk_down = SubResource( 3 ) 99 | anims/walk_left = SubResource( 2 ) 100 | anims/walk_right = SubResource( 4 ) 101 | anims/walk_up = SubResource( 5 ) 102 | -------------------------------------------------------------------------------- /client/Chatbox.gd: -------------------------------------------------------------------------------- 1 | extends Control 2 | 3 | onready var chat_log = get_node("CanvasLayer/VBoxContainer/RichTextLabel") 4 | onready var input_label = get_node("CanvasLayer/VBoxContainer/HBoxContainer/Label") 5 | onready var input_field = get_node("CanvasLayer/VBoxContainer/HBoxContainer/LineEdit") 6 | onready var button = get_node("CanvasLayer/VBoxContainer/HBoxContainer/Button") 7 | 8 | signal message_sent(message) 9 | 10 | 11 | func _ready(): 12 | input_field.connect("text_entered", self, "text_entered") 13 | button.connect("pressed", self, "button_pressed") 14 | 15 | 16 | func _input(event: InputEvent): 17 | if event is InputEventKey and event.pressed: 18 | match event.scancode: 19 | KEY_ENTER: 20 | input_field.grab_focus() 21 | KEY_ESCAPE: 22 | input_field.release_focus() 23 | 24 | 25 | func add_message(username, text: String): 26 | if username: 27 | chat_log.bbcode_text += username + ' says: "' + text + '"\n' 28 | else: 29 | # Server message 30 | chat_log.bbcode_text += "[color=yellow]" + text + "[/color]\n" 31 | 32 | 33 | func text_entered(text: String): 34 | if len(text) > 0: 35 | input_field.text = "" 36 | 37 | emit_signal("message_sent", text) 38 | 39 | func button_pressed(): 40 | text_entered(input_field.text) 41 | -------------------------------------------------------------------------------- /client/Chatbox.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=2] 2 | 3 | [ext_resource path="res://Chatbox.gd" type="Script" id=1] 4 | 5 | [node name="Chatbox" type="Control"] 6 | margin_right = 40.0 7 | margin_bottom = 40.0 8 | script = ExtResource( 1 ) 9 | __meta__ = { 10 | "_edit_use_anchors_": false 11 | } 12 | 13 | [node name="CanvasLayer" type="CanvasLayer" parent="."] 14 | 15 | [node name="VBoxContainer" type="VBoxContainer" parent="CanvasLayer"] 16 | anchor_top = 0.67 17 | anchor_right = 1.0 18 | anchor_bottom = 1.0 19 | margin_left = 10.0 20 | margin_right = -10.0 21 | margin_bottom = -10.0 22 | mouse_filter = 2 23 | 24 | [node name="RichTextLabel" type="RichTextLabel" parent="CanvasLayer/VBoxContainer"] 25 | margin_right = 1004.0 26 | margin_bottom = 160.0 27 | mouse_filter = 2 28 | size_flags_horizontal = 3 29 | size_flags_vertical = 3 30 | bbcode_enabled = true 31 | scroll_following = true 32 | 33 | [node name="HBoxContainer" type="HBoxContainer" parent="CanvasLayer/VBoxContainer"] 34 | margin_top = 164.0 35 | margin_right = 1004.0 36 | margin_bottom = 188.0 37 | 38 | [node name="Label" type="Label" parent="CanvasLayer/VBoxContainer/HBoxContainer"] 39 | margin_top = 5.0 40 | margin_right = 35.0 41 | margin_bottom = 19.0 42 | text = "[SAY]:" 43 | 44 | [node name="LineEdit" type="LineEdit" parent="CanvasLayer/VBoxContainer/HBoxContainer"] 45 | margin_left = 39.0 46 | margin_right = 957.0 47 | margin_bottom = 24.0 48 | size_flags_horizontal = 3 49 | __meta__ = { 50 | "_edit_use_anchors_": false 51 | } 52 | 53 | [node name="Button" type="Button" parent="CanvasLayer/VBoxContainer/HBoxContainer"] 54 | margin_left = 961.0 55 | margin_right = 1004.0 56 | margin_bottom = 24.0 57 | text = "Send" 58 | -------------------------------------------------------------------------------- /client/Login.gd: -------------------------------------------------------------------------------- 1 | extends Control 2 | 3 | onready var username_field: LineEdit = get_node("CanvasLayer/VBoxContainer/GridContainer/LineEdit_Username") 4 | onready var password_field: LineEdit = get_node("CanvasLayer/VBoxContainer/GridContainer/LineEdit_Password") 5 | onready var login_button: Button = get_node("CanvasLayer/VBoxContainer/CenterContainer/HBoxContainer/Button_Login") 6 | onready var register_button: Button = get_node("CanvasLayer/VBoxContainer/CenterContainer/HBoxContainer/Button_Register") 7 | 8 | onready var avatar_panel: Panel = get_node("CanvasLayer/Panel") 9 | onready var avatar_sprite: Sprite = get_node("CanvasLayer/Panel/Control/Avatar") 10 | onready var avatar_animation_player: AnimationPlayer = get_node("CanvasLayer/Panel/Control/Avatar/AnimationPlayer") 11 | onready var avatar_left: Button = get_node("CanvasLayer/Panel/VBoxContainer/HBoxContainer/Button_Left") 12 | onready var avatar_ok: Button = get_node("CanvasLayer/Panel/VBoxContainer/HBoxContainer/Button_Ok") 13 | onready var avatar_right: Button = get_node("CanvasLayer/Panel/VBoxContainer/HBoxContainer/Button_Right") 14 | 15 | var avatar_id = 0 16 | 17 | signal login(username, password) 18 | signal register(username, password) 19 | 20 | func _ready(): 21 | password_field.secret = true 22 | avatar_panel.visible = false 23 | 24 | login_button.connect("pressed", self, "_login") 25 | register_button.connect("pressed", self, "_choose_avatar") 26 | 27 | avatar_left.connect("pressed", self, "_next_avatar") 28 | avatar_ok.connect("pressed", self, "_register") 29 | avatar_right.connect("pressed", self, "_prev_avatar") 30 | 31 | func _login(): 32 | emit_signal("login", username_field.text, password_field.text) 33 | 34 | func _choose_avatar(): 35 | avatar_panel.visible = true 36 | avatar_animation_player.play("walk_down") 37 | 38 | func _next_avatar(): 39 | avatar_id += 1 40 | if avatar_id >= 6: 41 | avatar_id = 0 42 | _update_sprite() 43 | 44 | func _prev_avatar(): 45 | avatar_id -= 1 46 | if avatar_id < 0: 47 | avatar_id = 5 48 | _update_sprite() 49 | 50 | func _update_sprite(): 51 | avatar_sprite.set_region_rect(Rect2(368, avatar_id * 48, 64, 48)) 52 | 53 | func _register(): 54 | emit_signal("register", username_field.text, password_field.text, avatar_id) 55 | -------------------------------------------------------------------------------- /client/Login.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=3 format=2] 2 | 3 | [ext_resource path="res://Login.gd" type="Script" id=1] 4 | [ext_resource path="res://Avatar.tscn" type="PackedScene" id=2] 5 | 6 | [node name="Login" type="Control"] 7 | anchor_right = 1.0 8 | anchor_bottom = 1.0 9 | script = ExtResource( 1 ) 10 | 11 | [node name="CanvasLayer" type="CanvasLayer" parent="."] 12 | 13 | [node name="Panel" type="Panel" parent="CanvasLayer"] 14 | anchor_left = 0.4 15 | anchor_top = 0.25 16 | anchor_right = 0.6 17 | anchor_bottom = 0.33 18 | 19 | [node name="Control" type="Control" parent="CanvasLayer/Panel"] 20 | anchor_left = 0.5 21 | anchor_top = -1.0 22 | anchor_right = 0.5 23 | margin_left = -1.52588e-05 24 | margin_top = 10.8 25 | margin_right = -1.52588e-05 26 | margin_bottom = 10.8 27 | 28 | [node name="Avatar" parent="CanvasLayer/Panel/Control" instance=ExtResource( 2 )] 29 | 30 | [node name="VBoxContainer" type="VBoxContainer" parent="CanvasLayer/Panel"] 31 | anchor_left = 0.1 32 | anchor_top = 0.1 33 | anchor_right = 0.9 34 | anchor_bottom = 0.9 35 | size_flags_horizontal = 3 36 | size_flags_vertical = 3 37 | 38 | [node name="Label" type="Label" parent="CanvasLayer/Panel/VBoxContainer"] 39 | margin_right = 163.0 40 | margin_bottom = 14.0 41 | size_flags_horizontal = 3 42 | text = "Choose your avatar!" 43 | align = 1 44 | 45 | [node name="HBoxContainer" type="HBoxContainer" parent="CanvasLayer/Panel/VBoxContainer"] 46 | margin_top = 18.0 47 | margin_right = 163.0 48 | margin_bottom = 38.0 49 | size_flags_horizontal = 3 50 | size_flags_vertical = 3 51 | 52 | [node name="Button_Left" type="Button" parent="CanvasLayer/Panel/VBoxContainer/HBoxContainer"] 53 | margin_right = 51.0 54 | margin_bottom = 20.0 55 | size_flags_horizontal = 3 56 | text = "<" 57 | 58 | [node name="Button_Ok" type="Button" parent="CanvasLayer/Panel/VBoxContainer/HBoxContainer"] 59 | margin_left = 55.0 60 | margin_right = 107.0 61 | margin_bottom = 20.0 62 | size_flags_horizontal = 3 63 | text = "OK" 64 | 65 | [node name="Button_Right" type="Button" parent="CanvasLayer/Panel/VBoxContainer/HBoxContainer"] 66 | margin_left = 111.0 67 | margin_right = 163.0 68 | margin_bottom = 20.0 69 | size_flags_horizontal = 3 70 | text = ">" 71 | 72 | [node name="VBoxContainer" type="VBoxContainer" parent="CanvasLayer"] 73 | anchor_left = 0.2 74 | anchor_top = 0.4 75 | anchor_right = 0.8 76 | anchor_bottom = 0.6 77 | 78 | [node name="GridContainer" type="GridContainer" parent="CanvasLayer/VBoxContainer"] 79 | margin_right = 614.0 80 | margin_bottom = 52.0 81 | columns = 2 82 | 83 | [node name="Label_Username" type="Label" parent="CanvasLayer/VBoxContainer/GridContainer"] 84 | margin_top = 5.0 85 | margin_right = 74.0 86 | margin_bottom = 19.0 87 | text = "Username: " 88 | 89 | [node name="LineEdit_Username" type="LineEdit" parent="CanvasLayer/VBoxContainer/GridContainer"] 90 | margin_left = 78.0 91 | margin_right = 614.0 92 | margin_bottom = 24.0 93 | size_flags_horizontal = 3 94 | 95 | [node name="Label_Password" type="Label" parent="CanvasLayer/VBoxContainer/GridContainer"] 96 | margin_top = 33.0 97 | margin_right = 74.0 98 | margin_bottom = 47.0 99 | text = "Password: " 100 | __meta__ = { 101 | "_edit_use_anchors_": false 102 | } 103 | 104 | [node name="LineEdit_Password" type="LineEdit" parent="CanvasLayer/VBoxContainer/GridContainer"] 105 | margin_left = 78.0 106 | margin_top = 28.0 107 | margin_right = 614.0 108 | margin_bottom = 52.0 109 | size_flags_horizontal = 3 110 | __meta__ = { 111 | "_edit_use_anchors_": false 112 | } 113 | 114 | [node name="CenterContainer" type="CenterContainer" parent="CanvasLayer/VBoxContainer"] 115 | margin_top = 56.0 116 | margin_right = 614.0 117 | margin_bottom = 76.0 118 | 119 | [node name="HBoxContainer" type="HBoxContainer" parent="CanvasLayer/VBoxContainer/CenterContainer"] 120 | margin_left = 250.0 121 | margin_right = 364.0 122 | margin_bottom = 20.0 123 | 124 | [node name="Button_Login" type="Button" parent="CanvasLayer/VBoxContainer/CenterContainer/HBoxContainer"] 125 | margin_right = 46.0 126 | margin_bottom = 20.0 127 | text = "Login" 128 | 129 | [node name="Button_Register" type="Button" parent="CanvasLayer/VBoxContainer/CenterContainer/HBoxContainer"] 130 | margin_left = 50.0 131 | margin_right = 114.0 132 | margin_bottom = 20.0 133 | text = "Register" 134 | -------------------------------------------------------------------------------- /client/Main.gd: -------------------------------------------------------------------------------- 1 | extends Node 2 | 3 | # Imports 4 | const NetworkClient = preload("res://websockets_client.gd") 5 | const Packet = preload("res://packet.gd") 6 | const Chatbox = preload("res://Chatbox.tscn") 7 | const Actor = preload("res://Actor.tscn") 8 | 9 | onready var _network_client = NetworkClient.new() 10 | onready var _login_screen = get_node("Login") 11 | var _chatbox = null 12 | var state: FuncRef 13 | var _username: String 14 | var _actors: Dictionary = {} 15 | var _player_actor = null 16 | 17 | 18 | func _ready(): 19 | _network_client.connect("connected", self, "_handle_client_connected") 20 | _network_client.connect("disconnected", self, "_handle_client_disconnected") 21 | _network_client.connect("error", self, "_handle_network_error") 22 | _network_client.connect("data", self, "_handle_network_data") 23 | add_child(_network_client) 24 | _network_client.connect_to_server("godmmo.tx2600.net", 8081) 25 | 26 | _login_screen.connect("login", self, "_handle_login_button") 27 | _login_screen.connect("register", self, "_handle_register_button") 28 | state = null 29 | 30 | func LOGIN(p): 31 | match p.action: 32 | "Ok": 33 | _enter_game() 34 | "Deny": 35 | var reason: String = p.payloads[0] 36 | OS.alert(reason) 37 | 38 | func REGISTER(p): 39 | match p.action: 40 | "Ok": 41 | OS.alert("Registration successful") 42 | "Deny": 43 | var reason: String = p.payloads[0] 44 | OS.alert(reason) 45 | 46 | func PLAY(p): 47 | match p.action: 48 | "ModelDelta": 49 | var model_data: Dictionary = p.payloads[0] 50 | _update_models(model_data) 51 | "Chat": 52 | var username: String = p.payloads[0] 53 | var message: String = p.payloads[1] 54 | _chatbox.add_message(username, message) 55 | 56 | "Disconnect": 57 | var actor_id: int = p.payloads[0] 58 | var actor = _actors[actor_id] 59 | _chatbox.add_message(null, actor.actor_name + " has disconnected.") 60 | remove_child(actor) 61 | _actors.erase(actor_id) 62 | 63 | 64 | func _handle_login_button(username: String, password: String): 65 | state = funcref(self, "LOGIN") 66 | var p: Packet = Packet.new("Login", [username, password]) 67 | _network_client.send_packet(p) 68 | _username = username 69 | 70 | func _handle_register_button(username: String, password: String, avatar_id: int): 71 | state = funcref(self, "REGISTER") 72 | var p: Packet = Packet.new("Register", [username, password, avatar_id]) 73 | _network_client.send_packet(p) 74 | 75 | 76 | func _update_models(model_data: Dictionary): 77 | """ 78 | Runs a function with signature 79 | `_update_x(model_id: int, model_data: Dictionary)` where `x` is the name 80 | of a model (e.g. `_update_actor`). 81 | """ 82 | print("Received model data: %s" % JSON.print(model_data)) 83 | var model_id: int = model_data["id"] 84 | var func_name: String = "_update_" + model_data["model_type"].to_lower() 85 | var f: FuncRef = funcref(self, func_name) 86 | f.call_func(model_id, model_data) 87 | 88 | func _update_actor(model_id: int, model_data: Dictionary): 89 | # If this is an existing actor, just update them 90 | if model_id in _actors: 91 | _actors[model_id].update(model_data) 92 | 93 | # If this actor doesn't exist in the game yet, create them 94 | else: 95 | var new_actor 96 | 97 | if not _player_actor: 98 | _player_actor = Actor.instance().init(model_data) 99 | _player_actor.is_player = true 100 | new_actor = _player_actor 101 | else: 102 | new_actor = Actor.instance().init(model_data) 103 | 104 | _actors[model_id] = new_actor 105 | add_child(new_actor) 106 | 107 | func _enter_game(): 108 | state = funcref(self, "PLAY") 109 | 110 | # Remove the login screen 111 | remove_child(_login_screen) 112 | 113 | # Instance the chatbox 114 | _chatbox = Chatbox.instance() 115 | _chatbox.connect("message_sent", self, "send_chat") 116 | add_child(_chatbox) 117 | 118 | func send_chat(text: String): 119 | var p: Packet = Packet.new("Chat", [_username, text]) 120 | _network_client.send_packet(p) 121 | _chatbox.add_message(_username, text) 122 | 123 | func _handle_client_connected(): 124 | print("Client connected to server!") 125 | 126 | 127 | func _handle_client_disconnected(was_clean: bool): 128 | OS.alert("Disconnected %s" % ["cleanly" if was_clean else "unexpectedly"]) 129 | get_tree().quit() 130 | 131 | 132 | func _handle_network_data(data: String): 133 | print("Received server data: ", data) 134 | var action_payloads: Array = Packet.json_to_action_payloads(data) 135 | var p: Packet = Packet.new(action_payloads[0], action_payloads[1]) 136 | # Pass the packet to our current state 137 | state.call_func(p) 138 | 139 | 140 | func _handle_network_error(): 141 | OS.alert("There was an error") 142 | 143 | 144 | func _unhandled_input(event: InputEvent): 145 | if _player_actor and event.is_action_released("click"): 146 | var target = _player_actor.body.get_global_mouse_position() 147 | _player_actor._player_target = target 148 | var p: Packet = Packet.new("Target", [target.x, target.y]) 149 | _network_client.send_packet(p) 150 | 151 | 152 | -------------------------------------------------------------------------------- /client/Main.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=5 format=2] 2 | 3 | [ext_resource path="res://Main.gd" type="Script" id=1] 4 | [ext_resource path="res://Login.tscn" type="PackedScene" id=2] 5 | [ext_resource path="res://tilemap_packed.png" type="Texture" id=3] 6 | 7 | [sub_resource type="TileSet" id=1] 8 | 0/name = "tilemap_packed.png 0" 9 | 0/texture = ExtResource( 3 ) 10 | 0/tex_offset = Vector2( 0, 0 ) 11 | 0/modulate = Color( 1, 1, 1, 1 ) 12 | 0/region = Rect2( 0, 0, 128, 48 ) 13 | 0/tile_mode = 1 14 | 0/autotile/bitmask_mode = 1 15 | 0/autotile/bitmask_flags = [ Vector2( 0, 0 ), 432, Vector2( 0, 1 ), 438, Vector2( 0, 2 ), 54, Vector2( 1, 0 ), 504, Vector2( 1, 1 ), 511, Vector2( 1, 2 ), 63, Vector2( 2, 0 ), 216, Vector2( 2, 1 ), 219, Vector2( 2, 2 ), 27, Vector2( 3, 0 ), 176, Vector2( 3, 1 ), 50, Vector2( 3, 2 ), 48, Vector2( 4, 0 ), 152, Vector2( 4, 1 ), 26, Vector2( 4, 2 ), 56, Vector2( 5, 0 ), 255, Vector2( 5, 1 ), 507, Vector2( 5, 2 ), 24, Vector2( 6, 0 ), 447, Vector2( 6, 1 ), 510, Vector2( 6, 2 ), 16, Vector2( 7, 0 ), 144, Vector2( 7, 1 ), 146, Vector2( 7, 2 ), 18 ] 16 | 0/autotile/icon_coordinate = Vector2( 0, 0 ) 17 | 0/autotile/tile_size = Vector2( 16, 16 ) 18 | 0/autotile/spacing = 0 19 | 0/autotile/occluder_map = [ ] 20 | 0/autotile/navpoly_map = [ ] 21 | 0/autotile/priority_map = [ ] 22 | 0/autotile/z_index_map = [ ] 23 | 0/occluder_offset = Vector2( 0, 0 ) 24 | 0/navigation_offset = Vector2( 0, 0 ) 25 | 0/shape_offset = Vector2( 0, 0 ) 26 | 0/shape_transform = Transform2D( 1, 0, 0, 1, 0, 0 ) 27 | 0/shape_one_way = false 28 | 0/shape_one_way_margin = 0.0 29 | 0/shapes = [ ] 30 | 0/z_index = 0 31 | 1/name = "tilemap_packed.png 1" 32 | 1/texture = ExtResource( 3 ) 33 | 1/tex_offset = Vector2( 0, 0 ) 34 | 1/modulate = Color( 1, 1, 1, 1 ) 35 | 1/region = Rect2( 128, 0, 128, 48 ) 36 | 1/tile_mode = 1 37 | 1/autotile/bitmask_mode = 1 38 | 1/autotile/bitmask_flags = [ Vector2( 0, 0 ), 432, Vector2( 0, 1 ), 438, Vector2( 0, 2 ), 54, Vector2( 1, 0 ), 504, Vector2( 1, 1 ), 511, Vector2( 1, 2 ), 63, Vector2( 2, 0 ), 216, Vector2( 2, 1 ), 219, Vector2( 2, 2 ), 27, Vector2( 3, 0 ), 176, Vector2( 3, 1 ), 50, Vector2( 3, 2 ), 48, Vector2( 4, 0 ), 152, Vector2( 4, 1 ), 26, Vector2( 4, 2 ), 56, Vector2( 5, 0 ), 255, Vector2( 5, 1 ), 507, Vector2( 5, 2 ), 24, Vector2( 6, 0 ), 447, Vector2( 6, 1 ), 510, Vector2( 6, 2 ), 16, Vector2( 7, 0 ), 144, Vector2( 7, 1 ), 146, Vector2( 7, 2 ), 18 ] 39 | 1/autotile/icon_coordinate = Vector2( 0, 0 ) 40 | 1/autotile/tile_size = Vector2( 16, 16 ) 41 | 1/autotile/spacing = 0 42 | 1/autotile/occluder_map = [ ] 43 | 1/autotile/navpoly_map = [ ] 44 | 1/autotile/priority_map = [ ] 45 | 1/autotile/z_index_map = [ ] 46 | 1/occluder_offset = Vector2( 0, 0 ) 47 | 1/navigation_offset = Vector2( 0, 0 ) 48 | 1/shape_offset = Vector2( 0, 0 ) 49 | 1/shape_transform = Transform2D( 1, 0, 0, 1, 0, 0 ) 50 | 1/shape_one_way = false 51 | 1/shape_one_way_margin = 0.0 52 | 1/shapes = [ ] 53 | 1/z_index = 0 54 | 2/name = "tilemap_packed.png 2" 55 | 2/texture = ExtResource( 3 ) 56 | 2/tex_offset = Vector2( 0, 0 ) 57 | 2/modulate = Color( 1, 1, 1, 1 ) 58 | 2/region = Rect2( 0, 48, 128, 48 ) 59 | 2/tile_mode = 1 60 | 2/autotile/bitmask_mode = 1 61 | 2/autotile/bitmask_flags = [ Vector2( 0, 0 ), 432, Vector2( 0, 1 ), 438, Vector2( 0, 2 ), 54, Vector2( 1, 0 ), 504, Vector2( 1, 1 ), 511, Vector2( 1, 2 ), 63, Vector2( 2, 0 ), 216, Vector2( 2, 1 ), 219, Vector2( 2, 2 ), 27, Vector2( 3, 0 ), 176, Vector2( 3, 1 ), 50, Vector2( 3, 2 ), 48, Vector2( 4, 0 ), 152, Vector2( 4, 1 ), 26, Vector2( 4, 2 ), 56, Vector2( 5, 0 ), 255, Vector2( 5, 1 ), 507, Vector2( 5, 2 ), 24, Vector2( 6, 0 ), 447, Vector2( 6, 1 ), 510, Vector2( 6, 2 ), 16, Vector2( 7, 0 ), 144, Vector2( 7, 1 ), 146, Vector2( 7, 2 ), 18 ] 62 | 2/autotile/icon_coordinate = Vector2( 0, 0 ) 63 | 2/autotile/tile_size = Vector2( 16, 16 ) 64 | 2/autotile/spacing = 0 65 | 2/autotile/occluder_map = [ ] 66 | 2/autotile/navpoly_map = [ ] 67 | 2/autotile/priority_map = [ ] 68 | 2/autotile/z_index_map = [ ] 69 | 2/occluder_offset = Vector2( 0, 0 ) 70 | 2/navigation_offset = Vector2( 0, 0 ) 71 | 2/shape_offset = Vector2( 0, 0 ) 72 | 2/shape_transform = Transform2D( 1, 0, 0, 1, 0, 0 ) 73 | 2/shape_one_way = false 74 | 2/shape_one_way_margin = 0.0 75 | 2/shapes = [ ] 76 | 2/z_index = 0 77 | 3/name = "tilemap_packed.png 3" 78 | 3/texture = ExtResource( 3 ) 79 | 3/tex_offset = Vector2( 0, 0 ) 80 | 3/modulate = Color( 1, 1, 1, 1 ) 81 | 3/region = Rect2( 128, 48, 128, 48 ) 82 | 3/tile_mode = 1 83 | 3/autotile/bitmask_mode = 1 84 | 3/autotile/bitmask_flags = [ Vector2( 0, 0 ), 432, Vector2( 0, 1 ), 438, Vector2( 0, 2 ), 54, Vector2( 1, 0 ), 504, Vector2( 1, 1 ), 511, Vector2( 1, 2 ), 63, Vector2( 2, 0 ), 216, Vector2( 2, 1 ), 219, Vector2( 2, 2 ), 27, Vector2( 3, 0 ), 176, Vector2( 3, 1 ), 50, Vector2( 3, 2 ), 48, Vector2( 4, 0 ), 152, Vector2( 4, 1 ), 26, Vector2( 4, 2 ), 56, Vector2( 5, 0 ), 255, Vector2( 5, 1 ), 507, Vector2( 5, 2 ), 24, Vector2( 6, 0 ), 447, Vector2( 6, 1 ), 510, Vector2( 6, 2 ), 16, Vector2( 7, 0 ), 144, Vector2( 7, 1 ), 146, Vector2( 7, 2 ), 18 ] 85 | 3/autotile/icon_coordinate = Vector2( 0, 0 ) 86 | 3/autotile/tile_size = Vector2( 16, 16 ) 87 | 3/autotile/spacing = 0 88 | 3/autotile/occluder_map = [ ] 89 | 3/autotile/navpoly_map = [ ] 90 | 3/autotile/priority_map = [ ] 91 | 3/autotile/z_index_map = [ ] 92 | 3/occluder_offset = Vector2( 0, 0 ) 93 | 3/navigation_offset = Vector2( 0, 0 ) 94 | 3/shape_offset = Vector2( 0, 0 ) 95 | 3/shape_transform = Transform2D( 1, 0, 0, 1, 0, 0 ) 96 | 3/shape_one_way = false 97 | 3/shape_one_way_margin = 0.0 98 | 3/shapes = [ ] 99 | 3/z_index = 0 100 | 4/name = "tilemap_packed.png 4" 101 | 4/texture = ExtResource( 3 ) 102 | 4/tex_offset = Vector2( 0, 0 ) 103 | 4/modulate = Color( 1, 1, 1, 1 ) 104 | 4/region = Rect2( 128, 96, 128, 48 ) 105 | 4/tile_mode = 1 106 | 4/autotile/bitmask_mode = 1 107 | 4/autotile/bitmask_flags = [ Vector2( 0, 0 ), 432, Vector2( 0, 1 ), 438, Vector2( 0, 2 ), 54, Vector2( 1, 0 ), 504, Vector2( 1, 1 ), 511, Vector2( 1, 2 ), 63, Vector2( 2, 0 ), 216, Vector2( 2, 1 ), 219, Vector2( 2, 2 ), 27, Vector2( 3, 0 ), 176, Vector2( 3, 1 ), 50, Vector2( 3, 2 ), 48, Vector2( 4, 0 ), 152, Vector2( 4, 1 ), 26, Vector2( 4, 2 ), 56, Vector2( 5, 0 ), 255, Vector2( 5, 1 ), 507, Vector2( 5, 2 ), 24, Vector2( 6, 0 ), 447, Vector2( 6, 1 ), 510, Vector2( 6, 2 ), 16, Vector2( 7, 0 ), 144, Vector2( 7, 1 ), 146, Vector2( 7, 2 ), 18 ] 108 | 4/autotile/icon_coordinate = Vector2( 0, 0 ) 109 | 4/autotile/tile_size = Vector2( 16, 16 ) 110 | 4/autotile/spacing = 0 111 | 4/autotile/occluder_map = [ ] 112 | 4/autotile/navpoly_map = [ ] 113 | 4/autotile/priority_map = [ ] 114 | 4/autotile/z_index_map = [ ] 115 | 4/occluder_offset = Vector2( 0, 0 ) 116 | 4/navigation_offset = Vector2( 0, 0 ) 117 | 4/shape_offset = Vector2( 0, 0 ) 118 | 4/shape_transform = Transform2D( 1, 0, 0, 1, 0, 0 ) 119 | 4/shape_one_way = false 120 | 4/shape_one_way_margin = 0.0 121 | 4/shapes = [ ] 122 | 4/z_index = 0 123 | 124 | [node name="Main" type="Node2D"] 125 | script = ExtResource( 1 ) 126 | 127 | [node name="Login" parent="." instance=ExtResource( 2 )] 128 | 129 | [node name="TileMap" type="TileMap" parent="."] 130 | scale = Vector2( 4, 4 ) 131 | tile_set = SubResource( 1 ) 132 | cell_size = Vector2( 16, 16 ) 133 | format = 1 134 | tile_data = PoolIntArray( 0, 2, 0, 1, 2, 1, 2, 2, 1, 3, 2, 1, 4, 2, 1, 5, 2, 1, 6, 2, 1, 7, 2, 1, 8, 2, 1, 9, 2, 1, 10, 2, 1, 11, 2, 1, 12, 2, 1, 13, 2, 1, 14, 2, 1, 15, 2, 2, 16, 0, 0, 17, 0, 1, 18, 0, 1, 19, 0, 1, 20, 0, 2, 65536, 2, 65536, 65537, 2, 5, 65538, 2, 131073, 65539, 2, 131073, 65540, 2, 131073, 65541, 2, 131073, 65542, 2, 131073, 65543, 2, 131073, 65544, 2, 131073, 65545, 2, 131073, 65546, 2, 131073, 65547, 2, 131073, 65548, 2, 131073, 65549, 2, 131073, 65550, 2, 6, 65551, 2, 65538, 65552, 0, 65536, 65553, 0, 65537, 65554, 0, 65537, 65555, 0, 65537, 65556, 0, 65538, 131072, 2, 65536, 131073, 2, 65538, 131074, 4, 3, 131075, 4, 131076, 131076, 4, 131076, 131077, 4, 131076, 131078, 4, 131076, 131079, 4, 131076, 131080, 4, 131076, 131081, 4, 131076, 131082, 4, 131076, 131083, 4, 131076, 131084, 4, 131076, 131085, 4, 4, 131086, 2, 65536, 131087, 2, 65538, 131088, 0, 65536, 131089, 0, 65537, 131090, 0, 65537, 131091, 0, 65537, 131092, 0, 65538, 131116, 0, 131078, 196608, 2, 65536, 196609, 2, 65538, 196610, 4, 65543, 196611, 0, 0, 196612, 0, 1, 196613, 0, 1, 196614, 0, 1, 196615, 0, 1, 196616, 0, 1, 196617, 0, 1, 196618, 0, 1, 196619, 0, 1, 196620, 0, 2, 196621, 4, 65543, 196622, 2, 65536, 196623, 2, 65538, 196624, 0, 65536, 196625, 0, 65537, 196626, 0, 65537, 196627, 0, 65537, 196628, 0, 65538, 262144, 2, 65536, 262145, 2, 65538, 262146, 4, 65543, 262147, 0, 65536, 262148, 0, 65537, 262149, 0, 65537, 262150, 0, 65537, 262151, 0, 65537, 262152, 0, 65537, 262153, 0, 65537, 262154, 0, 65537, 262155, 0, 65537, 262156, 0, 65538, 262157, 4, 65543, 262158, 2, 131072, 262159, 2, 131074, 262160, 0, 131072, 262161, 0, 131073, 262162, 0, 6, 262163, 0, 65537, 262164, 0, 65538, 327680, 2, 65536, 327681, 2, 65538, 327682, 4, 65543, 327683, 0, 65536, 327684, 0, 65537, 327685, 0, 65537, 327686, 0, 65537, 327687, 0, 65537, 327688, 0, 65537, 327689, 0, 65537, 327690, 0, 65537, 327691, 0, 65537, 327692, 0, 65538, 327693, 3, 131075, 327694, 3, 131076, 327695, 3, 131076, 327696, 3, 131076, 327697, 3, 131077, 327698, 0, 65536, 327699, 0, 65537, 327700, 0, 65538, 393216, 2, 65536, 393217, 2, 65538, 393218, 4, 65543, 393219, 0, 131072, 393220, 0, 131073, 393221, 0, 131073, 393222, 0, 131073, 393223, 0, 131073, 393224, 0, 131073, 393225, 0, 131073, 393226, 0, 131073, 393227, 0, 131073, 393228, 0, 131074, 393229, 4, 65543, 393230, 2, 0, 393231, 2, 2, 393232, 0, 0, 393233, 0, 1, 393234, 0, 65542, 393235, 0, 65537, 393236, 0, 65538, 458752, 2, 65536, 458753, 2, 65538, 458754, 4, 65539, 458755, 4, 131076, 458756, 4, 131076, 458757, 4, 131076, 458758, 4, 131076, 458759, 3, 7, 458760, 4, 131076, 458761, 4, 131076, 458762, 4, 131076, 458763, 4, 131076, 458764, 4, 131076, 458765, 4, 65540, 458766, 2, 65536, 458767, 2, 65538, 458768, 0, 65536, 458769, 0, 65537, 458770, 0, 65537, 458771, 0, 65537, 458772, 0, 65538, 524288, 2, 65536, 524289, 2, 65541, 524290, 2, 1, 524291, 2, 1, 524292, 2, 1, 524293, 2, 1, 524294, 2, 2, 524295, 3, 65543, 524296, 2, 0, 524297, 2, 1, 524298, 2, 1, 524299, 2, 1, 524300, 2, 1, 524301, 2, 1, 524302, 2, 65542, 524303, 2, 65538, 524304, 0, 65536, 524305, 0, 65537, 524306, 0, 65537, 524307, 0, 65537, 524308, 0, 65538, 589824, 2, 131072, 589825, 2, 131073, 589826, 2, 131073, 589827, 2, 131073, 589828, 2, 131073, 589829, 2, 131073, 589830, 2, 131074, 589831, 3, 65543, 589832, 2, 131072, 589833, 2, 131073, 589834, 2, 131073, 589835, 2, 131073, 589836, 2, 131073, 589837, 2, 131073, 589838, 2, 131073, 589839, 2, 131074, 589840, 0, 65536, 589841, 0, 65537, 589842, 0, 65537, 589843, 0, 65537, 589844, 0, 65538, 655360, 0, 0, 655361, 0, 1, 655362, 0, 1, 655363, 0, 1, 655364, 0, 1, 655365, 0, 1, 655366, 0, 2, 655367, 3, 131079, 655368, 0, 0, 655369, 0, 1, 655370, 0, 1, 655371, 0, 1, 655372, 0, 1, 655373, 0, 1, 655374, 0, 1, 655375, 0, 1, 655376, 0, 65542, 655377, 0, 65537, 655378, 0, 65537, 655379, 0, 65537, 655380, 0, 65538, 720896, 0, 65536, 720897, 0, 65537, 720898, 0, 65537, 720899, 0, 65537, 720900, 0, 65537, 720901, 0, 65537, 720902, 0, 65541, 720903, 0, 1, 720904, 0, 65542, 720905, 0, 65537, 720906, 0, 65537, 720907, 0, 65537, 720908, 0, 65537, 720909, 0, 65537, 720910, 0, 65537, 720911, 0, 65537, 720912, 0, 65537, 720913, 0, 65537, 720914, 0, 65537, 720915, 0, 65537, 720916, 0, 65538, 786432, 0, 131072, 786433, 0, 131073, 786434, 0, 131073, 786435, 0, 131073, 786436, 0, 131073, 786437, 0, 131073, 786438, 0, 131073, 786439, 0, 131073, 786440, 0, 131073, 786441, 0, 131073, 786442, 0, 131073, 786443, 0, 131073, 786444, 0, 131073, 786445, 0, 131073, 786446, 0, 131073, 786447, 0, 131073, 786448, 0, 131073, 786449, 0, 131073, 786450, 0, 131073, 786451, 0, 131073, 786452, 0, 131074 ) 135 | -------------------------------------------------------------------------------- /client/default_env.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Environment" load_steps=2 format=2] 2 | 3 | [sub_resource type="ProceduralSky" id=1] 4 | 5 | [resource] 6 | background_mode = 2 7 | background_sky = SubResource( 1 ) 8 | -------------------------------------------------------------------------------- /client/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tristanbatchler/official-godot-python-mmo/859660f717750bd6b95057b9d37a1488d550191f/client/icon.png -------------------------------------------------------------------------------- /client/icon.png.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="StreamTexture" 5 | path="res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex" 6 | metadata={ 7 | "vram_texture": false 8 | } 9 | 10 | [deps] 11 | 12 | source_file="res://icon.png" 13 | dest_files=[ "res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex" ] 14 | 15 | [params] 16 | 17 | compress/mode=0 18 | compress/lossy_quality=0.7 19 | compress/hdr_mode=0 20 | compress/bptc_ldr=0 21 | compress/normal_map=0 22 | flags/repeat=0 23 | flags/filter=true 24 | flags/mipmaps=false 25 | flags/anisotropic=false 26 | flags/srgb=2 27 | process/fix_alpha_border=true 28 | process/premult_alpha=false 29 | process/HDR_as_SRGB=false 30 | process/invert_color=false 31 | process/normal_map_invert_y=false 32 | stream=false 33 | size_limit=0 34 | detect_3d=true 35 | svg/scale=1.0 36 | -------------------------------------------------------------------------------- /client/model.gd: -------------------------------------------------------------------------------- 1 | extends Node 2 | 3 | var initial_data: Dictionary 4 | var data: Dictionary = {} 5 | 6 | func init(init_data: Dictionary): 7 | initial_data = init_data 8 | return self 9 | 10 | func update(new_model: Dictionary): 11 | data = new_model 12 | -------------------------------------------------------------------------------- /client/packet.gd: -------------------------------------------------------------------------------- 1 | extends Object 2 | 3 | var action: String 4 | var payloads: Array 5 | 6 | 7 | func _init(_action: String, _payloads: Array): 8 | action = _action 9 | payloads = _payloads 10 | 11 | 12 | func tostring() -> String: 13 | var serlialize_dict: Dictionary = {"a": action} 14 | for i in range(len(payloads)): 15 | serlialize_dict["p%d" % i] = payloads[i] 16 | var data: String = JSON.print(serlialize_dict) 17 | return data 18 | 19 | 20 | static func json_to_action_payloads(json_str: String) -> Array: 21 | var action: String 22 | var payloads: Array = [] 23 | var obj_dict: Dictionary = JSON.parse(json_str).result 24 | 25 | for key in obj_dict.keys(): 26 | var value = obj_dict[key] 27 | if key == "a": 28 | action = value 29 | elif key[0] == "p": 30 | var index: int = key.split_floats("p", true)[1] 31 | payloads.insert(index, value) 32 | 33 | return [action, payloads] 34 | -------------------------------------------------------------------------------- /client/project.godot: -------------------------------------------------------------------------------- 1 | ; Engine configuration file. 2 | ; It's best edited using the editor UI and not directly, 3 | ; since the parameters that go here are not all obvious. 4 | ; 5 | ; Format: 6 | ; [section] ; section goes between [] 7 | ; param=value ; assign values to parameters 8 | 9 | config_version=4 10 | 11 | [application] 12 | 13 | config/name="client" 14 | run/main_scene="res://Main.tscn" 15 | config/icon="res://icon.png" 16 | 17 | [input] 18 | 19 | click={ 20 | "deadzone": 0.5, 21 | "events": [ Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"alt":false,"shift":false,"control":false,"meta":false,"command":false,"button_mask":0,"position":Vector2( 0, 0 ),"global_position":Vector2( 0, 0 ),"factor":1.0,"button_index":1,"pressed":false,"doubleclick":false,"script":null) 22 | ] 23 | } 24 | 25 | [physics] 26 | 27 | common/enable_pause_aware_picking=true 28 | 29 | [rendering] 30 | 31 | environment/default_environment="res://default_env.tres" 32 | -------------------------------------------------------------------------------- /client/tilemap_packed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tristanbatchler/official-godot-python-mmo/859660f717750bd6b95057b9d37a1488d550191f/client/tilemap_packed.png -------------------------------------------------------------------------------- /client/tilemap_packed.png.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="StreamTexture" 5 | path="res://.import/tilemap_packed.png-7b301e6705a57389d2cd02d385b33920.stex" 6 | metadata={ 7 | "vram_texture": false 8 | } 9 | 10 | [deps] 11 | 12 | source_file="res://tilemap_packed.png" 13 | dest_files=[ "res://.import/tilemap_packed.png-7b301e6705a57389d2cd02d385b33920.stex" ] 14 | 15 | [params] 16 | 17 | compress/mode=0 18 | compress/lossy_quality=0.7 19 | compress/hdr_mode=0 20 | compress/bptc_ldr=0 21 | compress/normal_map=0 22 | flags/repeat=0 23 | flags/filter=false 24 | flags/mipmaps=false 25 | flags/anisotropic=false 26 | flags/srgb=2 27 | process/fix_alpha_border=true 28 | process/premult_alpha=false 29 | process/HDR_as_SRGB=false 30 | process/invert_color=false 31 | process/normal_map_invert_y=false 32 | stream=false 33 | size_limit=0 34 | detect_3d=false 35 | svg/scale=1.0 36 | -------------------------------------------------------------------------------- /client/websockets_client.gd: -------------------------------------------------------------------------------- 1 | extends Node 2 | 3 | const Packet = preload("res://packet.gd") 4 | 5 | signal connected 6 | signal data 7 | signal disconnected 8 | signal error 9 | 10 | # Our WebSocketClient instance 11 | var _client = WebSocketClient.new() 12 | 13 | func _ready(): 14 | _client.connect("connection_closed", self, "_closed") 15 | _client.connect("connection_error", self, "_closed") 16 | _client.connect("connection_established", self, "_connected") 17 | _client.connect("data_received", self, "_on_data") 18 | _client.verify_ssl = false 19 | 20 | 21 | func connect_to_server(hostname: String, port: int) -> void: 22 | # Connects to the server or emits an error signal. 23 | # If connected, emits a connect signal. 24 | var websocket_url = "wss://%s:%d" % [hostname, port] 25 | var err = _client.connect_to_url(websocket_url) 26 | if err: 27 | print("Unable to connect") 28 | set_process(false) 29 | emit_signal("error") 30 | 31 | 32 | func send_packet(packet: Packet) -> void: 33 | # Sends a packet to the server 34 | _send_string(packet.tostring()) 35 | 36 | 37 | func _closed(was_clean = false): 38 | print("Closed, clean: ", was_clean) 39 | set_process(false) 40 | emit_signal("disconnected", was_clean) 41 | 42 | 43 | func _connected(proto = ""): 44 | print("Connected with protocol: ", proto) 45 | emit_signal("connected") 46 | 47 | 48 | func _on_data(): 49 | var data: String = _client.get_peer(1).get_packet().get_string_from_utf8() 50 | print("Got data from server: ", data) 51 | emit_signal("data", data) 52 | 53 | 54 | func _process(delta): 55 | _client.poll() 56 | 57 | 58 | func _send_string(string: String) -> void: 59 | _client.get_peer(1).put_packet(string.to_utf8()) 60 | print("Sent string ", string) 61 | -------------------------------------------------------------------------------- /copy-certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Copy this script to /bin (`sudo cp ./copy-certs.sh /bin`), make it executable (`sudo chmod +x /bin/copy-certs.sh`) and add the output of the following command to the root crontab (use `sudo crontab -e`): 3 | # ``` 4 | # echo "0 0 * * * /bin/copy-certs.sh $(pwd) $USER" 5 | # ``` 6 | 7 | set -e 8 | 9 | if [ "$(id -u)" -ne 0 ]; then 10 | echo "$(date) Please run this script as root." 11 | exit 1 12 | fi 13 | 14 | if [ "$#" -ne 2 ]; then 15 | echo "$(date) Must supply a project directory and a user who is running the game server." 16 | echo "$(date) If you are unsure which arguments to choose, cd into your project directory and run \"\$(pwd) \$USER\"" 17 | exit 1 18 | fi 19 | 20 | project_dir="$1" 21 | server_usr="$2" 22 | 23 | if [ ! -d "$project_dir" ]; then 24 | echo "$(date) Project directory $project_dir does not exist. Check you spelled it correctly and you've cloned your project into the correct location." 25 | exit 1 26 | fi 27 | 28 | server_dir="$project_dir/server" 29 | if [ ! -d "$project_dir" ]; then 30 | echo "$(date) Project directory exists, but could not find server folder ($server_dir). Check you cloned the repository correctly and haven't accidentally moved or deleted something." 31 | exit 1 32 | fi 33 | 34 | # Run the Let's Encrypt renewals if they're up for renewal 35 | echo "$(date) Attempting to renew Let's Encrypt certificates..." 36 | certbot renew 37 | 38 | # Copy the renewed certificates into the game server directory 39 | certs_dir="$server_dir/certs" 40 | if [ ! -d "$certs_dir" ]; then 41 | echo "$(date) Certificates folder not found. Automatically creating $certs_dir..." 42 | mkdir "$certs_dir" 43 | chown "$server_usr" "$certs_dir" 44 | fi 45 | 46 | echo "$(date) Attempting to copy Let's Encrypt certificates to $certs_dir" 47 | cp /etc/letsencrypt/live/*/fullchain.pem "$certs_dir/server.crt" 48 | cp /etc/letsencrypt/live/*/privkey.pem "$certs_dir/server.key" 49 | echo "$(date) Done" 50 | 51 | echo "$(date) Attempting to change ownership of $certs_dir/server.crt and $certs_dir/server.key to $server_usr" 52 | chown "$server_usr" "$certs_dir/server.crt" 53 | chown "$server_usr" "$certs_dir/server.key" 54 | echo "$(date) Done" 55 | 56 | exit 0 57 | -------------------------------------------------------------------------------- /server/__main__.py: -------------------------------------------------------------------------------- 1 | import manage # This must be at the top 2 | import sys 3 | import protocol 4 | from twisted.python import log 5 | from twisted.internet import reactor, task, ssl 6 | from autobahn.twisted.websocket import WebSocketServerFactory 7 | 8 | 9 | class GameFactory(WebSocketServerFactory): 10 | def __init__(self, hostname: str, port: int): 11 | self.protocol = protocol.GameServerProtocol 12 | super().__init__(f"wss://{hostname}:{port}") 13 | 14 | self.players: set[protocol.GameServerProtocol] = set() 15 | self.tickrate: int = 20 16 | self.user_ids_logged_in: set[int] = set() 17 | 18 | tickloop = task.LoopingCall(self.tick) 19 | tickloop.start(1 / self.tickrate) 20 | 21 | def tick(self): 22 | for p in self.players: 23 | p.tick() 24 | 25 | def remove_protocol(self, p: protocol.GameServerProtocol): 26 | self.players.remove(p) 27 | if p._actor and p._actor.user.id in self.user_ids_logged_in: 28 | self.user_ids_logged_in.remove(p._actor.user.id) 29 | 30 | 31 | # Override 32 | def buildProtocol(self, addr): 33 | p = super().buildProtocol(addr) 34 | self.players.add(p) 35 | return p 36 | 37 | 38 | if __name__ == '__main__': 39 | log.startLogging(sys.stdout) 40 | 41 | certs_dir: str = f"{sys.path[0]}/certs/" 42 | contextFactory = ssl.DefaultOpenSSLContextFactory(certs_dir + "server.key", certs_dir + "server.crt") 43 | 44 | PORT: int = 8081 45 | factory = GameFactory('0.0.0.0', PORT) 46 | 47 | reactor.listenSSL(PORT, factory, contextFactory) 48 | reactor.run() 49 | -------------------------------------------------------------------------------- /server/manage.py: -------------------------------------------------------------------------------- 1 | import django.conf 2 | import sys 3 | import pathlib 4 | 5 | # Required for importing the server app (upper dir) 6 | file = pathlib.Path(__file__).resolve() 7 | root = file.parents[1] 8 | sys.path.append(str(root)) 9 | 10 | INSTALLED_APPS = [ 11 | 'server', 12 | 'django.contrib.auth', 13 | 'django.contrib.contenttypes' 14 | ] 15 | 16 | DATABASES = { 17 | 'default': { 18 | 'ENGINE': 'django.db.backends.sqlite3', 19 | 'NAME': f'{root}/server/db.sqlite3' 20 | } 21 | } 22 | 23 | django.conf.settings.configure( 24 | INSTALLED_APPS=INSTALLED_APPS, 25 | DATABASES=DATABASES, 26 | DEFAULT_AUTO_FIELD='django.db.models.AutoField' 27 | ) 28 | 29 | django.setup() 30 | 31 | 32 | if __name__ == '__main__': 33 | from django.core.management import execute_from_command_line 34 | execute_from_command_line(sys.argv) -------------------------------------------------------------------------------- /server/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.forms import model_to_dict 3 | from django.contrib.auth import models as auth_models 4 | User = auth_models.User 5 | 6 | def create_dict(model: models.Model) -> dict: 7 | """ 8 | Recursively creates a dictionary based on the supplied model and all its foreign relationships. 9 | """ 10 | d: dict = model_to_dict(model) 11 | model_type: type = type(model) 12 | d["model_type"] = model_type.__name__ 13 | 14 | if model_type == InstancedEntity: 15 | d["entity"] = create_dict(model.entity) 16 | 17 | elif model_type == Actor: 18 | d["instanced_entity"] = create_dict(model.instanced_entity) 19 | # Purposefully don't include user information here. 20 | 21 | return d 22 | 23 | def get_delta_dict(model_dict_before: dict, model_dict_after: dict) -> dict: 24 | """ 25 | Returns a dictionary containing all differences between the supplied model dicts 26 | (except for the ID and Model Type). 27 | """ 28 | 29 | delta: dict = {} 30 | 31 | for k in model_dict_before.keys() & model_dict_after.keys(): # Intersection of keysets 32 | v_before = model_dict_before[k] 33 | v_after = model_dict_after[k] 34 | 35 | if k in ("id", "model_type"): 36 | delta[k] = v_after 37 | if v_before == v_after: 38 | continue 39 | 40 | if not isinstance(v_before, dict): 41 | delta[k] = v_after 42 | else: 43 | delta[k] = get_delta_dict(v_before, v_after) 44 | 45 | return delta 46 | 47 | class Entity(models.Model): 48 | name = models.CharField(max_length=100) 49 | 50 | class InstancedEntity(models.Model): 51 | x = models.FloatField() 52 | y = models.FloatField() 53 | entity = models.ForeignKey(Entity, on_delete=models.CASCADE) 54 | 55 | class Actor(models.Model): 56 | user = models.OneToOneField(User, on_delete=models.CASCADE) 57 | instanced_entity = models.OneToOneField(InstancedEntity, on_delete=models.CASCADE) 58 | avatar_id = models.IntegerField(default=0) 59 | -------------------------------------------------------------------------------- /server/packet.py: -------------------------------------------------------------------------------- 1 | import json 2 | import enum 3 | 4 | 5 | class Action(enum.Enum): 6 | Ok = enum.auto() 7 | Deny = enum.auto() 8 | Disconnect = enum.auto() 9 | Login = enum.auto() 10 | Register = enum.auto() 11 | Chat = enum.auto() 12 | ModelDelta = enum.auto() 13 | Target = enum.auto() 14 | 15 | 16 | class Packet: 17 | def __init__(self, action: Action, *payloads): 18 | self.action: Action = action 19 | self.payloads: tuple = payloads 20 | 21 | def __str__(self) -> str: 22 | serialize_dict = {'a': self.action.name} 23 | for i in range(len(self.payloads)): 24 | serialize_dict[f'p{i}'] = self.payloads[i] 25 | data = json.dumps(serialize_dict, separators=(',', ':')) 26 | return data 27 | 28 | def __bytes__(self) -> bytes: 29 | return str(self).encode('utf-8') 30 | 31 | class OkPacket(Packet): 32 | def __init__(self): 33 | super().__init__(Action.Ok) 34 | 35 | class DenyPacket(Packet): 36 | def __init__(self, reason: str): 37 | super().__init__(Action.Deny, reason) 38 | 39 | class DisconnectPacket(Packet): 40 | def __init__(self, actor_id: int): 41 | super().__init__(Action.Disconnect, actor_id) 42 | 43 | class LoginPacket(Packet): 44 | def __init__(self, username: str, password: str): 45 | super().__init__(Action.Login, username, password) 46 | 47 | class RegisterPacket(Packet): 48 | def __init__(self, username: str, password: str, avatar_id: int): 49 | super().__init__(Action.Register, username, password, avatar_id) 50 | 51 | class ChatPacket(Packet): 52 | def __init__(self, sender: str, message: str): 53 | super().__init__(Action.Chat, sender, message) 54 | 55 | class ModelDeltaPacket(Packet): 56 | def __init__(self, model_data: dict): 57 | super().__init__(Action.ModelDelta, model_data) 58 | 59 | class TargetPacket(Packet): 60 | def __init__(self, t_x: float, t_y: float): 61 | super().__init__(Action.Target, t_x, t_y) 62 | 63 | 64 | def from_json(json_str: str) -> Packet: 65 | obj_dict = json.loads(json_str) 66 | 67 | action = None 68 | payloads = [] 69 | for key, value in obj_dict.items(): 70 | if key == 'a': 71 | action = value 72 | 73 | elif key[0] == 'p': 74 | index = int(key[1:]) 75 | payloads.insert(index, value) 76 | 77 | # Use reflection to construct the specific packet type we're looking for 78 | class_name = action + "Packet" 79 | try: 80 | constructor: type = globals()[class_name] 81 | return constructor(*payloads) 82 | except KeyError as e: 83 | print( 84 | f"{class_name} is not a valid packet name. Stacktrace: {e}") 85 | except TypeError: 86 | print( 87 | f"{class_name} can't handle arguments {tuple(payloads)}.") 88 | -------------------------------------------------------------------------------- /server/protocol.py: -------------------------------------------------------------------------------- 1 | import math 2 | import utils 3 | import queue 4 | import time 5 | from server import packet 6 | from server import models 7 | from autobahn.twisted.websocket import WebSocketServerProtocol 8 | from autobahn.exception import Disconnected 9 | from django.contrib.auth import authenticate 10 | 11 | class GameServerProtocol(WebSocketServerProtocol): 12 | def __init__(self): 13 | super().__init__() 14 | self._packet_queue: queue.Queue[tuple['GameServerProtocol', packet.Packet]] = queue.Queue() 15 | self._state: callable = self.LOGIN 16 | self._actor: models.Actor = None 17 | self._player_target: list = None 18 | self._last_delta_time_checked = None 19 | self._known_others: set['GameServerProtocol'] = set() 20 | 21 | def LOGIN(self, sender: 'GameServerProtocol', p: packet.Packet): 22 | if p.action == packet.Action.Login: 23 | username, password = p.payloads 24 | 25 | # Try to get an existing user whose credentials match 26 | user = authenticate(username=username, password=password) 27 | 28 | # If credentials don't match, deny and return 29 | if not user: 30 | self.send_client(packet.DenyPacket("Username or password incorrect")) 31 | return 32 | 33 | # If user already logged in, deny and return 34 | if user.id in self.factory.user_ids_logged_in: 35 | self.send_client(packet.DenyPacket("You are already logged in")) 36 | return 37 | 38 | # Otherwise, proceed 39 | self._actor = models.Actor.objects.get(user=user) 40 | self.send_client(packet.OkPacket()) 41 | 42 | # Send full model data the first time we log in 43 | self.broadcast(packet.ModelDeltaPacket(models.create_dict(self._actor))) 44 | 45 | self.factory.user_ids_logged_in.add(user.id) 46 | 47 | self._state = self.PLAY 48 | 49 | 50 | elif p.action == packet.Action.Register: 51 | username, password, avatar_id = p.payloads 52 | 53 | if not username or not password: 54 | self.send_client(packet.DenyPacket("Username or password must not be empty")) 55 | return 56 | 57 | if models.User.objects.filter(username=username).exists(): 58 | self.send_client(packet.DenyPacket("This username is already taken")) 59 | return 60 | 61 | user = models.User.objects.create_user(username=username, password=password) 62 | user.save() 63 | player_entity = models.Entity(name=username) 64 | player_entity.save() 65 | player_ientity = models.InstancedEntity(entity=player_entity, x=0, y=0) 66 | player_ientity.save() 67 | player = models.Actor(instanced_entity=player_ientity, user=user, avatar_id=avatar_id) 68 | player.save() 69 | self.send_client(packet.OkPacket()) 70 | 71 | def PLAY(self, sender: 'GameServerProtocol', p: packet.Packet): 72 | if p.action == packet.Action.Chat: 73 | if sender == self: 74 | self.broadcast(p, exclude_self=True) 75 | else: 76 | self.send_client(p) 77 | 78 | elif p.action == packet.Action.ModelDelta: 79 | self.send_client(p) 80 | if sender not in self._known_others: 81 | # Send our full model data to the new player 82 | sender.onPacket(self, packet.ModelDeltaPacket(models.create_dict(self._actor))) 83 | self._known_others.add(sender) 84 | 85 | elif p.action == packet.Action.Target: 86 | self._player_target = p.payloads 87 | 88 | elif p.action == packet.Action.Disconnect: 89 | self._known_others.remove(sender) 90 | self.send_client(p) 91 | 92 | def _update_position(self) -> bool: 93 | "Attempt to update the actor's position and return true only if the position was changed" 94 | if not self._player_target: 95 | return False 96 | pos = [self._actor.instanced_entity.x, self._actor.instanced_entity.y] 97 | 98 | now: float = time.time() 99 | delta_time: float = 1 / self.factory.tickrate 100 | if self._last_delta_time_checked: 101 | delta_time = now - self._last_delta_time_checked 102 | self._last_delta_time_checked = now 103 | 104 | # Use delta time to calculate distance to travel this time 105 | dist: float = 70 * delta_time 106 | 107 | # Early exit if we are already within an acceptable distance of the target 108 | if math.dist(pos, self._player_target) < dist: 109 | return False 110 | 111 | # Update our model if we're not already close enough to the target 112 | d_x, d_y = utils.direction_to(pos, self._player_target) 113 | self._actor.instanced_entity.x += d_x * dist 114 | self._actor.instanced_entity.y += d_y * dist 115 | self._actor.instanced_entity.save() 116 | 117 | return True 118 | 119 | def tick(self): 120 | # Process the next packet in the queue 121 | if not self._packet_queue.empty(): 122 | s, p = self._packet_queue.get() 123 | self._state(s, p) 124 | 125 | # To do when there are no packets to process 126 | elif self._state == self.PLAY: 127 | actor_dict_before: dict = models.create_dict(self._actor) 128 | if self._update_position(): 129 | actor_dict_after: dict = models.create_dict(self._actor) 130 | self.broadcast(packet.ModelDeltaPacket(models.get_delta_dict(actor_dict_before, actor_dict_after))) 131 | 132 | 133 | def broadcast(self, p: packet.Packet, exclude_self: bool = False): 134 | for other in self.factory.players: 135 | if other == self and exclude_self: 136 | continue 137 | other.onPacket(self, p) 138 | 139 | # Override 140 | def onConnect(self, request): 141 | print(f"Client connecting: {request.peer}") 142 | 143 | # Override 144 | def onOpen(self): 145 | print(f"Websocket connection open.") 146 | 147 | # Override 148 | def onClose(self, wasClean, code, reason): 149 | if self._actor: 150 | self._actor.save() 151 | self.broadcast(packet.DisconnectPacket(self._actor.id), exclude_self=True) 152 | self.factory.remove_protocol(self) 153 | print(f"Websocket connection closed{' unexpectedly' if not wasClean else ' cleanly'} with code {code}: {reason}") 154 | 155 | # Override 156 | def onMessage(self, payload, isBinary): 157 | decoded_payload = payload.decode('utf-8') 158 | 159 | try: 160 | p: packet.Packet = packet.from_json(decoded_payload) 161 | except Exception as e: 162 | print(f"Could not load message as packet: {e}. Message was: {payload.decode('utf8')}") 163 | 164 | self.onPacket(self, p) 165 | 166 | def onPacket(self, sender: 'GameServerProtocol', p: packet.Packet): 167 | self._packet_queue.put((sender, p)) 168 | print(f"Queued packet: {p}") 169 | 170 | def send_client(self, p: packet.Packet): 171 | b = bytes(p) 172 | try: 173 | self.sendMessage(b) 174 | except Disconnected: 175 | print(f"Couldn't send {p} because client disconnected.") 176 | 177 | 178 | -------------------------------------------------------------------------------- /server/utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | def direction_to(current: list, target: list) -> list: 4 | "Return the vector with unit length pointing in the direction from current to target" 5 | if target == current: 6 | return [0, 0] 7 | 8 | n_x = target[0] - current[0] 9 | n_y = target[1] - current[1] 10 | 11 | length = math.dist(current, target) 12 | return [n_x / length, n_y / length] --------------------------------------------------------------------------------