777 lines
29 KiB
GDScript
777 lines
29 KiB
GDScript
extends Node
|
|
## Title screen: Continue, New World, Load World (with delete), Quit.
|
|
## The island itself, in menu mode, slowly flies past in the background.
|
|
|
|
const UITheme = preload("res://scripts/ui/theme.gd")
|
|
const WORLD_SCENE := "res://scenes/world.tscn"
|
|
|
|
var _pages: Dictionary = {} ## name -> Control
|
|
var _name_edit: LineEdit
|
|
var _seed_edit: LineEdit
|
|
var _rot_pick: OptionButton
|
|
## The page to open on (the tutorial's "Start your own world" sends you to "new").
|
|
static var start_page := ""
|
|
## [setting, what the New World page calls it]
|
|
const ROT_CHOICES := [["normal", "Normally (raw meat lasts 2 days)"], ["slow", "Slowly (twice as long)"], ["off", "Never"]]
|
|
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
|
|
var _lobby_note: Label
|
|
|
|
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
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
|
|
var backdrop: Node3D = load(WORLD_SCENE).instantiate()
|
|
backdrop.menu_mode = true
|
|
add_child(backdrop)
|
|
# The backdrop's (disabled) player captures the mouse in its _ready; take
|
|
# it back, or the menu can't be clicked.
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
|
|
var ui := CanvasLayer.new()
|
|
ui.layer = 10
|
|
add_child(ui)
|
|
var root := Control.new()
|
|
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
ui.add_child(root)
|
|
|
|
# Soft vignette on the left so the text reads over the scenery.
|
|
var shade := TextureRect.new()
|
|
var g := Gradient.new()
|
|
g.offsets = PackedFloat32Array([0.0, 1.0])
|
|
g.colors = PackedColorArray([Color(0, 0, 0, 0.6), Color(0, 0, 0, 0)])
|
|
var gt := GradientTexture2D.new()
|
|
gt.gradient = g
|
|
gt.fill_to = Vector2(1, 0)
|
|
shade.texture = gt
|
|
shade.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
|
|
shade.offset_right = 720
|
|
shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
root.add_child(shade)
|
|
|
|
var col := VBoxContainer.new()
|
|
col.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
|
|
col.offset_left = 70
|
|
col.offset_top = 90
|
|
col.offset_right = 520
|
|
col.add_theme_constant_override("separation", 14)
|
|
root.add_child(col)
|
|
col.add_child(UITheme.label("EMBERWILD", 54))
|
|
col.add_child(UITheme.label("Survive the island. Build a home.", 18, UITheme.TEXT_DIM))
|
|
var spacer := Control.new()
|
|
spacer.custom_minimum_size = Vector2(0, 24)
|
|
col.add_child(spacer)
|
|
_build_update_banner(root)
|
|
|
|
_pages["main"] = _main_page()
|
|
_pages["new"] = _new_page()
|
|
_pages["load"] = _load_page()
|
|
_pages["profile"] = _profile_page()
|
|
_pages["join"] = _join_page()
|
|
_pages["lobby"] = _lobby_page()
|
|
_pages["host"] = _host_page()
|
|
for p in _pages.values():
|
|
col.add_child(p)
|
|
Net.host_status.connect(_on_host_status)
|
|
Net.joined.connect(_enter_server_world)
|
|
Net.join_failed.connect(_on_join_failed)
|
|
Net.left.connect(_on_left)
|
|
Net.players_changed.connect(func():
|
|
if _pages["lobby"].visible:
|
|
_fill_lobby())
|
|
_show(start_page if start_page in _pages else "main")
|
|
start_page = ""
|
|
# Back from a server (left, kicked, or it went away): say why.
|
|
if Net.state == "offline" and not Net.last_reason.is_empty():
|
|
_show("join")
|
|
_join_msg.text = Net.last_reason
|
|
Net.last_reason = ""
|
|
_maybe_capture()
|
|
|
|
## The server let us in: show who's there for a moment, then load the island.
|
|
func _enter_server_world() -> void:
|
|
_show("lobby")
|
|
_lobby_note.text = "Loading the island..."
|
|
for _i in range(3):
|
|
await get_tree().process_frame
|
|
if Net.state != "connected":
|
|
return
|
|
Saves.current_id = "" # a server's world isn't saved locally
|
|
get_tree().change_scene_to_file(WORLD_SCENE)
|
|
|
|
## Dev hook, same as world.gd: `-- --shot=/abs/prefix` saves the title pages.
|
|
func _maybe_capture() -> void:
|
|
var prefix := ""
|
|
for a in OS.get_cmdline_user_args():
|
|
if a.begins_with("--shot="):
|
|
prefix = a.trim_prefix("--shot=")
|
|
if prefix.is_empty():
|
|
return
|
|
await get_tree().create_timer(1.0).timeout
|
|
print("[title] mouse mode: %s" % ["visible", "hidden", "captured", "confined"][Input.mouse_mode])
|
|
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", "host", "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
|
|
get_viewport().get_texture().get_image().save_png("%s_title_%s.png" % [prefix, page])
|
|
print("[title] saved %s_title_%s.png" % [prefix, page])
|
|
get_tree().quit()
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible:
|
|
if _pages["lobby"].visible:
|
|
Net.leave()
|
|
elif Net.state == "connecting" or Net.hosting:
|
|
Net.stop_hosting()
|
|
Net.stop()
|
|
_join_msg.text = ""
|
|
_show("main")
|
|
else:
|
|
_show("main")
|
|
|
|
func _show(page: String) -> void:
|
|
# Joining (or hosting) needs a name: send people to their profile first.
|
|
if page in ["join", "host"] and Identity.name_problem(Net.get_identity().name) != "":
|
|
_after_profile = page
|
|
page = "profile"
|
|
if page == "host":
|
|
_fill_host()
|
|
for k in _pages:
|
|
_pages[k].visible = k == page
|
|
_armed_delete = ""
|
|
if page == "profile":
|
|
_fill_profile()
|
|
elif page == "join":
|
|
_join_shown_at = Time.get_ticks_msec()
|
|
_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":
|
|
_name_edit.text = _unique_name()
|
|
_seed_edit.text = ""
|
|
_rot_pick.select(0)
|
|
_name_edit.grab_focus()
|
|
_name_edit.select_all()
|
|
|
|
func _button(text: String, cb: Callable, min_w: int = 320) -> Button:
|
|
var b := Button.new()
|
|
b.text = text
|
|
b.focus_mode = Control.FOCUS_NONE
|
|
b.custom_minimum_size = Vector2(min_w, 46)
|
|
b.add_theme_font_size_override("font_size", 19)
|
|
b.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
b.pressed.connect(cb)
|
|
return b
|
|
|
|
func _main_page() -> Control:
|
|
var v := VBoxContainer.new()
|
|
v.add_theme_constant_override("separation", 10)
|
|
var worlds := Saves.list_worlds()
|
|
# Continue is for your own worlds: the tutorial has its own button below.
|
|
var own := worlds.filter(func(w): return not bool(w.get("tutorial", false)))
|
|
if worlds.is_empty():
|
|
v.add_child(UITheme.label("New here? Try the tutorial (about 20 minutes).", 15, UITheme.TEXT_DIM))
|
|
elif not own.is_empty():
|
|
var w: Dictionary = own[0]
|
|
v.add_child(_button("Continue: %s" % w.name, _play.bind(w.id)))
|
|
var tut := tutorial_world()
|
|
v.add_child(_button("Tutorial" if tut.is_empty() else "Tutorial (carry on)", _play_tutorial))
|
|
v.add_child(_button("New World", _show.bind("new")))
|
|
var lb := _button("Load World", _show.bind("load"))
|
|
lb.disabled = worlds.is_empty()
|
|
v.add_child(lb)
|
|
v.add_child(_button("Host Game", _show.bind("host")))
|
|
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", func(): 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)
|
|
|
|
var _join_shown_at := -100000
|
|
|
|
# --- hosting (M2.7) -------------------------------------------------------------
|
|
|
|
var _host_name: LineEdit
|
|
var _host_pw: LineEdit
|
|
var _host_port: LineEdit
|
|
var _host_seed: LineEdit
|
|
var _host_seed_label: Label
|
|
var _host_preset: OptionButton
|
|
var _host_msg: Label
|
|
var _host_btn: Button
|
|
const PRESET_IDS := ["co-op", "rivals", "adversarial"]
|
|
const PRESET_NAMES := ["Co-op: no PvP, bases safe, friends share", "Friendly rivals: PvP on, bases safe",
|
|
"Adversarial: PvP, raiding, locks, drop everything on death"]
|
|
|
|
func _host_page() -> Control:
|
|
var p := UITheme.panel()
|
|
var v := VBoxContainer.new()
|
|
v.add_theme_constant_override("separation", 7)
|
|
p.add_child(v)
|
|
v.add_child(UITheme.label("Host a game", 24))
|
|
v.add_child(UITheme.label("Runs a server on this computer and puts you in it. Friends join with your address.", 13, UITheme.TEXT_DIM))
|
|
v.add_child(UITheme.label("Server name", 15, UITheme.TEXT_DIM))
|
|
_host_name = LineEdit.new()
|
|
_host_name.custom_minimum_size = Vector2(420, 38)
|
|
_host_name.max_length = 40
|
|
v.add_child(_host_name)
|
|
v.add_child(UITheme.label("Rules", 15, UITheme.TEXT_DIM))
|
|
_host_preset = OptionButton.new()
|
|
for n in PRESET_NAMES:
|
|
_host_preset.add_item(n)
|
|
_host_preset.custom_minimum_size = Vector2(420, 36)
|
|
v.add_child(_host_preset)
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 10)
|
|
var c1 := VBoxContainer.new()
|
|
c1.add_child(UITheme.label("Password (optional)", 15, UITheme.TEXT_DIM))
|
|
_host_pw = LineEdit.new()
|
|
_host_pw.secret = true
|
|
_host_pw.custom_minimum_size = Vector2(200, 38)
|
|
c1.add_child(_host_pw)
|
|
row.add_child(c1)
|
|
var c2 := VBoxContainer.new()
|
|
c2.add_child(UITheme.label("Port (UDP)", 15, UITheme.TEXT_DIM))
|
|
_host_port = LineEdit.new()
|
|
_host_port.custom_minimum_size = Vector2(100, 38)
|
|
c2.add_child(_host_port)
|
|
row.add_child(c2)
|
|
var c3 := VBoxContainer.new()
|
|
_host_seed_label = UITheme.label("Seed", 15, UITheme.TEXT_DIM)
|
|
c3.add_child(_host_seed_label)
|
|
_host_seed = LineEdit.new()
|
|
_host_seed.placeholder_text = "random"
|
|
_host_seed.custom_minimum_size = Vector2(100, 38)
|
|
c3.add_child(_host_seed)
|
|
row.add_child(c3)
|
|
v.add_child(row)
|
|
_host_msg = UITheme.label("", 14, UITheme.TEXT_DIM)
|
|
_host_msg.autowrap_mode = TextServer.AUTOWRAP_WORD
|
|
_host_msg.custom_minimum_size = Vector2(420, 0)
|
|
v.add_child(_host_msg)
|
|
var h := HBoxContainer.new()
|
|
h.add_theme_constant_override("separation", 10)
|
|
_host_btn = _button("Start", _start_host, 140)
|
|
h.add_child(_host_btn)
|
|
h.add_child(_button("Back", func():
|
|
Net.stop_hosting()
|
|
Net.stop()
|
|
_show("main"), 120))
|
|
v.add_child(h)
|
|
return p
|
|
|
|
func _fill_host() -> void:
|
|
var c: RefCounted = load("res://scripts/net/server_config.gd").open(Net.host_config)
|
|
var n: String = c.data.server_name
|
|
_host_name.text = "%s's island" % Net.get_identity().name if n == "Emberwild server" else n
|
|
_host_port.text = str(int(c.data.port))
|
|
_host_pw.text = String(c.data.password)
|
|
var preset := "co-op"
|
|
if bool(c.data.pvp):
|
|
preset = "adversarial" if bool(c.data.raiding) else "rivals"
|
|
_host_preset.select(PRESET_IDS.find(preset))
|
|
var meta_path: String = Net.host_config.get_base_dir().path_join("worlds/server-world/meta.json")
|
|
var existing: bool = FileAccess.file_exists(meta_path)
|
|
_host_seed.editable = not existing
|
|
_host_seed_label.text = "Seed" if not existing else "Seed (kept)"
|
|
_host_seed.text = "" if not existing else str(int(c.data.seed))
|
|
_host_msg.text = "Your hosted island carries on where you left it." if existing else "A new island will be made."
|
|
_host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
|
|
_host_btn.disabled = false
|
|
|
|
func _start_host() -> void:
|
|
var port_t := _host_port.text.strip_edges()
|
|
if not port_t.is_valid_int() or int(port_t) < 1024 or int(port_t) > 65535:
|
|
_on_host_status("The port is a number from 1024 to 65535 (24242 is the usual one)", true)
|
|
return
|
|
var seed_t := _host_seed.text.strip_edges()
|
|
var seed := int(seed_t) if seed_t.is_valid_int() else (seed_t.hash() if not seed_t.is_empty() else randi() % 1000000)
|
|
var name := _host_name.text.strip_edges()
|
|
if name.is_empty():
|
|
name = "%s's island" % Net.get_identity().name
|
|
_host_btn.disabled = true
|
|
Net.host(name, _host_pw.text, int(port_t), PRESET_IDS[maxi(_host_preset.selected, 0)], seed)
|
|
|
|
func _on_host_status(text: String, failed: bool) -> void:
|
|
if not _pages["host"].visible:
|
|
return
|
|
_host_msg.text = text
|
|
_host_msg.add_theme_color_override("font_color", UITheme.BAD if failed else UITheme.TEXT_DIM)
|
|
if failed:
|
|
_host_btn.disabled = false
|
|
|
|
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:
|
|
# (A click or Enter carried over from the page before can't connect.)
|
|
if Time.get_ticks_msec() - _join_shown_at < 400:
|
|
return
|
|
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:
|
|
if _pages["host"].visible:
|
|
_on_host_status(reason, true)
|
|
return
|
|
_join_btn.disabled = false
|
|
_join_msg.text = reason + version_advice(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)
|
|
_lobby_note = UITheme.label("", 13, UITheme.TEXT_DIM)
|
|
_lobby_note.autowrap_mode = TextServer.AUTOWRAP_WORD
|
|
_lobby_note.custom_minimum_size = Vector2(420, 0)
|
|
v.add_child(_lobby_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()
|
|
v.add_theme_constant_override("separation", 8)
|
|
p.add_child(v)
|
|
v.add_child(UITheme.label("New World", 24))
|
|
v.add_child(UITheme.label("Name", 15, UITheme.TEXT_DIM))
|
|
_name_edit = LineEdit.new()
|
|
_name_edit.custom_minimum_size = Vector2(360, 40)
|
|
_name_edit.max_length = 32
|
|
_name_edit.text_submitted.connect(func(_t): _create())
|
|
v.add_child(_name_edit)
|
|
v.add_child(UITheme.label("Seed (blank = random island, 1337 = the original)", 15, UITheme.TEXT_DIM))
|
|
_seed_edit = LineEdit.new()
|
|
_seed_edit.placeholder_text = "random"
|
|
_seed_edit.custom_minimum_size = Vector2(360, 40)
|
|
_seed_edit.text_submitted.connect(func(_t): _create())
|
|
v.add_child(_seed_edit)
|
|
v.add_child(UITheme.label("Food spoils", 15, UITheme.TEXT_DIM))
|
|
_rot_pick = OptionButton.new()
|
|
for t in ROT_CHOICES:
|
|
_rot_pick.add_item(t[1])
|
|
_rot_pick.custom_minimum_size = Vector2(360, 36)
|
|
v.add_child(_rot_pick)
|
|
var h := HBoxContainer.new()
|
|
h.add_theme_constant_override("separation", 10)
|
|
h.add_child(_button("Create & Play", _create, 170))
|
|
h.add_child(_button("Back", _show.bind("main"), 120))
|
|
v.add_child(h)
|
|
return p
|
|
|
|
func _load_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("Load World", 24))
|
|
var scroll := ScrollContainer.new()
|
|
scroll.custom_minimum_size = Vector2(460, 320)
|
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
_list = VBoxContainer.new()
|
|
_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_list.add_theme_constant_override("separation", 6)
|
|
scroll.add_child(_list)
|
|
v.add_child(scroll)
|
|
v.add_child(_button("Back", _show.bind("main"), 120))
|
|
return p
|
|
|
|
func _fill_list() -> void:
|
|
for c in _list.get_children():
|
|
c.queue_free()
|
|
var worlds := Saves.list_worlds()
|
|
if worlds.is_empty():
|
|
_list.add_child(UITheme.label("No worlds yet.", 16, UITheme.TEXT_DIM))
|
|
return
|
|
for w in worlds:
|
|
var row := PanelContainer.new()
|
|
row.add_theme_stylebox_override("panel", UITheme.box(UITheme.SLOT_BG, UITheme.SLOT_BORDER, 1, 5, 8))
|
|
var h := HBoxContainer.new()
|
|
h.add_theme_constant_override("separation", 8)
|
|
row.add_child(h)
|
|
var info := VBoxContainer.new()
|
|
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
info.add_child(UITheme.label(w.name, 18))
|
|
var played := int(w.get("play_time", 0.0))
|
|
var when := Time.get_datetime_string_from_unix_time(int(w.get("last_played", 0)), true)
|
|
info.add_child(UITheme.label("seed %d · played %dh %02dm · %s" % [int(w.seed), played / 3600, (played / 60) % 60, when], 13, UITheme.TEXT_DIM))
|
|
h.add_child(info)
|
|
h.add_child(_button("Play", _play.bind(w.id), 80))
|
|
var del := Button.new()
|
|
del.text = "Delete"
|
|
del.focus_mode = Control.FOCUS_NONE
|
|
del.custom_minimum_size = Vector2(90, 46)
|
|
del.add_theme_font_size_override("font_size", 19)
|
|
del.pressed.connect(_on_delete.bind(w.id, del))
|
|
h.add_child(del)
|
|
_list.add_child(row)
|
|
|
|
func _on_delete(id: String, b: Button) -> void:
|
|
if _armed_delete != id:
|
|
_armed_delete = id
|
|
b.text = "Sure?"
|
|
return
|
|
Saves.delete_world(id)
|
|
_armed_delete = ""
|
|
_fill_list()
|
|
if Saves.list_worlds().is_empty():
|
|
_rebuild_main()
|
|
|
|
func _rebuild_main() -> void:
|
|
var old: Control = _pages["main"]
|
|
var parent := old.get_parent()
|
|
var idx := old.get_index()
|
|
old.queue_free()
|
|
_pages["main"] = _main_page()
|
|
parent.add_child(_pages["main"])
|
|
parent.move_child(_pages["main"], idx)
|
|
_pages["main"].visible = false
|
|
|
|
func _unique_name() -> String:
|
|
var names := Saves.list_worlds().map(func(w): return w.name)
|
|
var n := names.size() + 1
|
|
var base := "Island"
|
|
while ("%s %d" % [base, n]) in names:
|
|
n += 1
|
|
return "%s %d" % [base, n]
|
|
|
|
func _create() -> void:
|
|
var world_name := _name_edit.text.strip_edges()
|
|
if world_name.is_empty():
|
|
world_name = _unique_name()
|
|
var seed_text := _seed_edit.text.strip_edges()
|
|
var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi())
|
|
var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0]
|
|
_play(Saves.create_world(world_name, seed, {"rot": rot}))
|
|
|
|
## The tutorial (docs/TUTORIAL_DESIGN.md): its own world on the original
|
|
## island (seed 1337), made the first time and carried on after that.
|
|
const TUTORIAL_SEED := 1337
|
|
|
|
static func tutorial_world() -> Dictionary:
|
|
for w in Saves.list_worlds():
|
|
if bool(w.get("tutorial", false)):
|
|
return w
|
|
return {}
|
|
|
|
func _play_tutorial() -> void:
|
|
var w := tutorial_world()
|
|
_play(String(w.id) if not w.is_empty() else Saves.create_world("Tutorial", TUTORIAL_SEED, {"tutorial": true}))
|
|
|
|
func _play(id: String) -> void:
|
|
Saves.current_id = id
|
|
get_tree().change_scene_to_file(WORLD_SCENE)
|
|
|
|
# --- updates (scripts/core/updates.gd) ------------------------------------------------
|
|
|
|
## Asked once per run of the game, not every time you come back to the title.
|
|
static var _update_asked := false
|
|
var update_banner: PanelContainer
|
|
var _update_text: Label
|
|
var _update_btn: Button
|
|
var version_label: Label
|
|
|
|
## The version, bottom left; and a card bottom right when a newer one is out.
|
|
func _build_update_banner(root: Control) -> void:
|
|
# (bottom right: the menu column runs down the left and would cover it)
|
|
version_label = UITheme.label("Emberwild %s" % Updates.version, 13, UITheme.TEXT_DIM)
|
|
version_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
|
|
version_label.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
|
version_label.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
|
version_label.offset_right = -16
|
|
version_label.offset_bottom = -10
|
|
root.add_child(version_label)
|
|
update_banner = UITheme.panel()
|
|
update_banner.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
|
|
update_banner.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
|
update_banner.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
|
update_banner.offset_right = -24
|
|
update_banner.offset_bottom = -40 # (above the version)
|
|
update_banner.visible = false
|
|
root.add_child(update_banner)
|
|
var v := VBoxContainer.new()
|
|
v.add_theme_constant_override("separation", 8)
|
|
v.custom_minimum_size = Vector2(360, 0)
|
|
update_banner.add_child(v)
|
|
_update_text = UITheme.label("", 16)
|
|
_update_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_update_text.custom_minimum_size = Vector2(360, 0)
|
|
v.add_child(_update_text)
|
|
var h := HBoxContainer.new()
|
|
h.add_theme_constant_override("separation", 8)
|
|
v.add_child(h)
|
|
_update_btn = _button("Download", _on_update_download, 170)
|
|
h.add_child(_update_btn)
|
|
h.add_child(_button("Not now", func(): update_banner.visible = false, 120))
|
|
Updates.checked.connect(_on_update_checked)
|
|
if Updates.state == "available":
|
|
_on_update_checked("available")
|
|
elif not _update_asked:
|
|
_update_asked = true
|
|
Updates.check()
|
|
|
|
func _on_update_checked(s: String) -> void:
|
|
if s != "available" or update_banner == null:
|
|
return
|
|
var notes := String(Updates.latest.get("notes", ""))
|
|
var how := "" if Updates.packaged() else "\nRunning from the source: git pull in the game folder, then restart."
|
|
_update_text.text = "Emberwild %s is out (you have %s).%s%s" % [Updates.latest_version(), Updates.version,
|
|
("\n" + notes) if not notes.is_empty() else "", how]
|
|
_update_btn.text = "Download" if Updates.packaged() and not String(Updates.download().get("url", "")).is_empty() else "Release page"
|
|
_update_btn.disabled = Updates.download_url().is_empty()
|
|
update_banner.visible = true
|
|
|
|
func _on_update_download() -> void:
|
|
var u := Updates.download_url()
|
|
if not u.is_empty():
|
|
OS.shell_open(u)
|
|
|
|
## Extra words for a "Different game version" refusal: what to do about it.
|
|
static func version_advice(reason: String) -> String:
|
|
if not reason.begins_with("Different game version"):
|
|
return ""
|
|
if Updates.state == "available":
|
|
return "\nEmberwild %s is out: Download is on the title page." % Updates.latest_version()
|
|
if reason.ends_with("Update the game to join."):
|
|
return "\nThere's no newer release yet: the server may be running a newer copy from the source."
|
|
return ""
|