M2.1: dedicated server, joining, identity handshake, names, lobby
- Net autoload: headless dedicated server (--server) on UDP 24242 via ENet, server.json preferences (flags override per run, never saved), reserved names in names.json - Identity: RSA-2048 key per install (Godot has no Ed25519), player id = key hash, challenge/response so ids can't be faked; profile with name and limited appearance (tunic, hair, beard) - Server refuses bans, allowlist misses, duplicate logins, bad or taken names, wrong passwords, old protocol versions and a full server, each with a clear reason; first player on a new server becomes admin - Title screen: Profile, Join Game, and a lobby with server info, rules and who's online - test_net_lobby: real headless server + bot processes over UDP (38 checks); --exit-with so test servers never outlive the test
This commit is contained in:
@@ -11,7 +11,29 @@ var _seed_edit: LineEdit
|
||||
var _list: VBoxContainer
|
||||
var _armed_delete := ""
|
||||
|
||||
const Identity = preload("res://scripts/net/identity.gd")
|
||||
var _after_profile := "main" ## page to return to after saving the profile
|
||||
var _prof_name: LineEdit
|
||||
var _prof_msg: Label
|
||||
var _prof_tag: Label
|
||||
var _prof_beard: Button
|
||||
var _prof_tunic: Array = []
|
||||
var _prof_hair: Array = []
|
||||
var _prof_fig: Dictionary = {}
|
||||
var _prof_app: Dictionary = {}
|
||||
var _join_addr: LineEdit
|
||||
var _join_pw: LineEdit
|
||||
var _join_msg: Label
|
||||
var _join_btn: Button
|
||||
var _lobby_title: Label
|
||||
var _lobby_motd: Label
|
||||
var _lobby_rules: Label
|
||||
var _lobby_count: Label
|
||||
var _lobby_list: VBoxContainer
|
||||
|
||||
func _ready() -> void:
|
||||
if Net.dedicated:
|
||||
return # a headless server has no menu (and doesn't need the backdrop world)
|
||||
Saves.current_id = ""
|
||||
get_tree().auto_accept_quit = true
|
||||
get_tree().paused = false
|
||||
@@ -61,8 +83,17 @@ func _ready() -> void:
|
||||
_pages["main"] = _main_page()
|
||||
_pages["new"] = _new_page()
|
||||
_pages["load"] = _load_page()
|
||||
_pages["profile"] = _profile_page()
|
||||
_pages["join"] = _join_page()
|
||||
_pages["lobby"] = _lobby_page()
|
||||
for p in _pages.values():
|
||||
col.add_child(p)
|
||||
Net.joined.connect(func(): _show("lobby"))
|
||||
Net.join_failed.connect(_on_join_failed)
|
||||
Net.left.connect(_on_left)
|
||||
Net.players_changed.connect(func():
|
||||
if _pages["lobby"].visible:
|
||||
_fill_lobby())
|
||||
_show("main")
|
||||
_maybe_capture()
|
||||
|
||||
@@ -76,7 +107,18 @@ func _maybe_capture() -> void:
|
||||
return
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
print("[title] mouse mode: %s" % ["visible", "hidden", "captured", "confined"][Input.mouse_mode])
|
||||
for page in ["main", "new", "load"]:
|
||||
var me: RefCounted = Net.get_identity()
|
||||
if me.name.is_empty():
|
||||
me.name = "Paul" # in memory only, for the pictures
|
||||
for page in ["main", "new", "load", "profile", "join", "lobby"]:
|
||||
if page == "lobby":
|
||||
# Sample data: the lobby normally needs a live server.
|
||||
Net.server_info = {"server_name": "Paul's island", "motd": "Welcome! Be nice to the wolves.",
|
||||
"max_players": 8, "pvp": false, "raiding": false, "difficulty": "normal"}
|
||||
Net.players = {1: {"name": "Paul", "admin": true, "appearance": {"tunic": 1}},
|
||||
2: {"name": "Freya", "admin": false, "appearance": {"tunic": 2}},
|
||||
3: {"name": "Old Bjorn", "admin": false, "appearance": {"tunic": 3}}}
|
||||
Net.local_peer = 1
|
||||
_show(page)
|
||||
await get_tree().create_timer(4.0 if page == "main" else 0.5).timeout
|
||||
await RenderingServer.frame_post_draw
|
||||
@@ -86,12 +128,31 @@ func _maybe_capture() -> void:
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible:
|
||||
_show("main")
|
||||
if _pages["lobby"].visible:
|
||||
Net.leave()
|
||||
elif Net.state == "connecting":
|
||||
Net.stop()
|
||||
_join_msg.text = ""
|
||||
_show("main")
|
||||
else:
|
||||
_show("main")
|
||||
|
||||
func _show(page: String) -> void:
|
||||
# Joining needs a name: send people to their profile first.
|
||||
if page == "join" and Identity.name_problem(Net.get_identity().name) != "":
|
||||
_after_profile = "join"
|
||||
page = "profile"
|
||||
for k in _pages:
|
||||
_pages[k].visible = k == page
|
||||
_armed_delete = ""
|
||||
if page == "profile":
|
||||
_fill_profile()
|
||||
elif page == "join":
|
||||
_join_addr.text = Net.get_identity().last_server
|
||||
_join_btn.disabled = false
|
||||
_join_addr.grab_focus()
|
||||
elif page == "lobby":
|
||||
_fill_lobby()
|
||||
if page == "load":
|
||||
_fill_list()
|
||||
elif page == "new":
|
||||
@@ -121,9 +182,234 @@ func _main_page() -> Control:
|
||||
var lb := _button("Load World", _show.bind("load"))
|
||||
lb.disabled = worlds.is_empty()
|
||||
v.add_child(lb)
|
||||
v.add_child(_button("Join Game", _show.bind("join")))
|
||||
var n: String = Net.get_identity().name
|
||||
v.add_child(_button("Profile: %s" % n if not n.is_empty() else "Set Up Your Profile",
|
||||
func(): _after_profile = "main"; _show("profile")))
|
||||
v.add_child(_button("Quit", get_tree().quit))
|
||||
return v
|
||||
|
||||
# --- multiplayer: profile, join, lobby ----------------------------------------
|
||||
|
||||
func _profile_page() -> Control:
|
||||
var p := UITheme.panel()
|
||||
var h := HBoxContainer.new()
|
||||
h.add_theme_constant_override("separation", 22)
|
||||
p.add_child(h)
|
||||
var v := VBoxContainer.new()
|
||||
v.add_theme_constant_override("separation", 8)
|
||||
h.add_child(v)
|
||||
v.add_child(UITheme.label("Your profile", 24))
|
||||
v.add_child(UITheme.label("Name (shown over your character to other players)", 15, UITheme.TEXT_DIM))
|
||||
_prof_name = LineEdit.new()
|
||||
_prof_name.custom_minimum_size = Vector2(300, 40)
|
||||
_prof_name.max_length = 16
|
||||
_prof_name.text_changed.connect(func(_t): _update_profile_preview())
|
||||
_prof_name.text_submitted.connect(func(_t): _save_profile())
|
||||
v.add_child(_prof_name)
|
||||
v.add_child(UITheme.label("Tunic", 15, UITheme.TEXT_DIM))
|
||||
v.add_child(_swatch_row(Identity.TUNICS, "tunic", _prof_tunic))
|
||||
v.add_child(UITheme.label("Hair", 15, UITheme.TEXT_DIM))
|
||||
v.add_child(_swatch_row(Identity.HAIR, "hair", _prof_hair))
|
||||
_prof_beard = Button.new()
|
||||
_prof_beard.toggle_mode = true
|
||||
_prof_beard.focus_mode = Control.FOCUS_NONE
|
||||
_prof_beard.custom_minimum_size = Vector2(140, 34)
|
||||
_prof_beard.add_theme_font_size_override("font_size", 16)
|
||||
_prof_beard.toggled.connect(func(on): _prof_app.beard = on; _update_profile_preview())
|
||||
v.add_child(_prof_beard)
|
||||
_prof_msg = UITheme.label("", 14, UITheme.TEXT_DIM)
|
||||
v.add_child(_prof_msg)
|
||||
var b := HBoxContainer.new()
|
||||
b.add_theme_constant_override("separation", 10)
|
||||
b.add_child(_button("Save", _save_profile, 140))
|
||||
b.add_child(_button("Back", func(): _show("main"), 120))
|
||||
v.add_child(b)
|
||||
# A little figure: hair, face, beard, tunic.
|
||||
var fig := VBoxContainer.new()
|
||||
fig.add_theme_constant_override("separation", 0)
|
||||
fig.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
_prof_tag = UITheme.label("", 15)
|
||||
_prof_tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
fig.add_child(_prof_tag)
|
||||
_prof_fig = {}
|
||||
for part in [["hair", Vector2(44, 14)], ["face", Vector2(40, 30)], ["beard", Vector2(34, 12)], ["tunic", Vector2(64, 60)]]:
|
||||
var c := CenterContainer.new()
|
||||
var r := ColorRect.new()
|
||||
r.custom_minimum_size = part[1]
|
||||
c.add_child(r)
|
||||
fig.add_child(c)
|
||||
_prof_fig[part[0]] = r
|
||||
_prof_fig.face.color = Color(0.86, 0.68, 0.55)
|
||||
h.add_child(fig)
|
||||
return p
|
||||
|
||||
func _swatch_row(colors: Array, key: String, store: Array) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 6)
|
||||
for i in range(colors.size()):
|
||||
var s := Button.new()
|
||||
s.custom_minimum_size = Vector2(36, 30)
|
||||
s.focus_mode = Control.FOCUS_NONE
|
||||
s.pressed.connect(func(): _prof_app[key] = i; _update_profile_preview())
|
||||
row.add_child(s)
|
||||
store.append(s)
|
||||
return row
|
||||
|
||||
func _fill_profile() -> void:
|
||||
var me: RefCounted = Net.get_identity()
|
||||
_prof_name.text = me.name
|
||||
_prof_app = me.appearance.duplicate()
|
||||
_prof_beard.set_pressed_no_signal(bool(_prof_app.beard))
|
||||
_prof_msg.text = "Pick a name to join servers." if me.name.is_empty() else ""
|
||||
_update_profile_preview()
|
||||
_prof_name.grab_focus()
|
||||
|
||||
func _update_profile_preview() -> void:
|
||||
for pair in [[_prof_tunic, Identity.TUNICS, "tunic"], [_prof_hair, Identity.HAIR, "hair"]]:
|
||||
for i in range(pair[0].size()):
|
||||
var sel: bool = int(_prof_app[pair[2]]) == i
|
||||
var st := UITheme.box(pair[1][i], UITheme.SELECTED if sel else UITheme.SLOT_BORDER, 3 if sel else 1, 4, 0)
|
||||
for state in ["normal", "hover", "pressed"]:
|
||||
pair[0][i].add_theme_stylebox_override(state, st)
|
||||
_prof_beard.text = "Beard: %s" % ("yes" if _prof_app.beard else "no")
|
||||
var bst := UITheme.box(UITheme.SLOT_BG, UITheme.SELECTED if _prof_app.beard else UITheme.SLOT_BORDER, 2 if _prof_app.beard else 1, 4, 6)
|
||||
for state in ["normal", "hover", "pressed", "hover_pressed"]:
|
||||
_prof_beard.add_theme_stylebox_override(state, bst)
|
||||
var hair: Color = Identity.HAIR[int(_prof_app.hair)]
|
||||
_prof_fig.hair.color = hair
|
||||
_prof_fig.beard.color = hair if _prof_app.beard else Color(0, 0, 0, 0)
|
||||
_prof_fig.tunic.color = Identity.TUNICS[int(_prof_app.tunic)]
|
||||
var n := _prof_name.text
|
||||
_prof_tag.text = n if not n.is_empty() else "your name"
|
||||
var bad := Identity.name_problem(n)
|
||||
_prof_msg.text = bad if not n.is_empty() else "Pick a name to join servers."
|
||||
_prof_msg.add_theme_color_override("font_color", UITheme.BAD if not bad.is_empty() and not n.is_empty() else UITheme.TEXT_DIM)
|
||||
|
||||
func _save_profile() -> void:
|
||||
var bad := Identity.name_problem(_prof_name.text)
|
||||
if not bad.is_empty():
|
||||
_update_profile_preview()
|
||||
return
|
||||
var me: RefCounted = Net.get_identity()
|
||||
me.name = _prof_name.text
|
||||
me.appearance = Identity.sanitize_appearance(_prof_app)
|
||||
me.save_profile()
|
||||
_rebuild_main()
|
||||
_show(_after_profile)
|
||||
|
||||
func _join_page() -> Control:
|
||||
var p := UITheme.panel()
|
||||
var v := VBoxContainer.new()
|
||||
v.add_theme_constant_override("separation", 8)
|
||||
p.add_child(v)
|
||||
v.add_child(UITheme.label("Join a server", 24))
|
||||
v.add_child(UITheme.label("Address (host, or host:port; the default port is %d)" % Net.DEFAULT_PORT, 15, UITheme.TEXT_DIM))
|
||||
_join_addr = LineEdit.new()
|
||||
_join_addr.placeholder_text = "e.g. 100.101.102.103 or homeserver:24242"
|
||||
_join_addr.custom_minimum_size = Vector2(400, 40)
|
||||
_join_addr.text_submitted.connect(func(_t): _connect())
|
||||
v.add_child(_join_addr)
|
||||
v.add_child(UITheme.label("Password (if the server has one)", 15, UITheme.TEXT_DIM))
|
||||
_join_pw = LineEdit.new()
|
||||
_join_pw.secret = true
|
||||
_join_pw.custom_minimum_size = Vector2(400, 40)
|
||||
_join_pw.text_submitted.connect(func(_t): _connect())
|
||||
v.add_child(_join_pw)
|
||||
v.add_child(UITheme.label("On Tailscale, use the server's 100.x address or its machine name.", 13, UITheme.TEXT_DIM))
|
||||
_join_msg = UITheme.label("", 15, UITheme.TEXT_DIM)
|
||||
_join_msg.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
_join_msg.custom_minimum_size = Vector2(400, 0)
|
||||
v.add_child(_join_msg)
|
||||
var h := HBoxContainer.new()
|
||||
h.add_theme_constant_override("separation", 10)
|
||||
_join_btn = _button("Connect", _connect, 160)
|
||||
h.add_child(_join_btn)
|
||||
h.add_child(_button("Back", func():
|
||||
Net.stop()
|
||||
_join_msg.text = ""
|
||||
_show("main"), 120))
|
||||
v.add_child(h)
|
||||
return p
|
||||
|
||||
func _connect() -> void:
|
||||
if _join_addr.text.strip_edges().is_empty():
|
||||
_join_msg.text = "Enter the server's address"
|
||||
_join_msg.add_theme_color_override("font_color", UITheme.BAD)
|
||||
return
|
||||
_join_msg.text = "Connecting to %s..." % _join_addr.text.strip_edges()
|
||||
_join_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
|
||||
_join_btn.disabled = true
|
||||
Net.join(_join_addr.text, _join_pw.text)
|
||||
|
||||
func _on_join_failed(reason: String) -> void:
|
||||
_join_btn.disabled = false
|
||||
_join_msg.text = reason
|
||||
_join_msg.add_theme_color_override("font_color", UITheme.BAD)
|
||||
|
||||
func _on_left(reason: String) -> void:
|
||||
_show("join")
|
||||
_join_msg.text = reason
|
||||
_join_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
|
||||
|
||||
func _lobby_page() -> Control:
|
||||
var p := UITheme.panel()
|
||||
var v := VBoxContainer.new()
|
||||
v.add_theme_constant_override("separation", 8)
|
||||
p.add_child(v)
|
||||
_lobby_title = UITheme.label("", 24)
|
||||
v.add_child(_lobby_title)
|
||||
_lobby_motd = UITheme.label("", 15, UITheme.TEXT_DIM)
|
||||
_lobby_motd.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
_lobby_motd.custom_minimum_size = Vector2(420, 0)
|
||||
v.add_child(_lobby_motd)
|
||||
_lobby_rules = UITheme.label("", 14, UITheme.TEXT_DIM)
|
||||
v.add_child(_lobby_rules)
|
||||
_lobby_count = UITheme.label("", 17)
|
||||
v.add_child(_lobby_count)
|
||||
_lobby_list = VBoxContainer.new()
|
||||
_lobby_list.add_theme_constant_override("separation", 4)
|
||||
v.add_child(_lobby_list)
|
||||
var note := UITheme.label("Playing together in the world comes next (M2.2-M2.5). For now, this is who's here.", 13, UITheme.TEXT_DIM)
|
||||
note.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
note.custom_minimum_size = Vector2(420, 0)
|
||||
v.add_child(note)
|
||||
v.add_child(_button("Disconnect", Net.leave, 180))
|
||||
return p
|
||||
|
||||
func _fill_lobby() -> void:
|
||||
var s: Dictionary = Net.server_info
|
||||
_lobby_title.text = String(s.get("server_name", "Server"))
|
||||
_lobby_motd.text = String(s.get("motd", ""))
|
||||
var mode := "Co-op"
|
||||
if s.get("pvp", false):
|
||||
mode = "Adversarial" if s.get("raiding", false) else "Friendly rivals"
|
||||
_lobby_rules.text = "%s · PvP %s · raiding %s · %s difficulty" % [mode, "on" if s.get("pvp", false) else "off",
|
||||
"on" if s.get("raiding", false) else "off", s.get("difficulty", "normal")]
|
||||
_lobby_count.text = "Players (%d/%d)" % [Net.players.size(), int(s.get("max_players", 0))]
|
||||
for c in _lobby_list.get_children():
|
||||
c.queue_free()
|
||||
var peers := Net.players.keys()
|
||||
peers.sort_custom(func(a, b): return String(Net.players[a].name).to_lower() < String(Net.players[b].name).to_lower())
|
||||
for peer in peers:
|
||||
var pl: Dictionary = Net.players[peer]
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
var sw := ColorRect.new()
|
||||
sw.custom_minimum_size = Vector2(16, 16)
|
||||
sw.color = Identity.TUNICS[int(pl.get("appearance", {}).get("tunic", 0))]
|
||||
var c := CenterContainer.new()
|
||||
c.add_child(sw)
|
||||
row.add_child(c)
|
||||
var tags := []
|
||||
if peer == Net.local_peer:
|
||||
tags.append("you")
|
||||
if pl.get("admin", false):
|
||||
tags.append("admin")
|
||||
row.add_child(UITheme.label(String(pl.name) + (" (%s)" % ", ".join(tags) if not tags.is_empty() else ""), 16,
|
||||
UITheme.SELECTED if peer == Net.local_peer else UITheme.TEXT))
|
||||
_lobby_list.add_child(row)
|
||||
|
||||
func _new_page() -> Control:
|
||||
var p := UITheme.panel()
|
||||
var v := VBoxContainer.new()
|
||||
|
||||
Reference in New Issue
Block a user