Files
Emberwild/scripts/ui/title_screen.gd

856 lines
33 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)
_host_worldlet = _worldlet_picker(v)
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))
_show_host_worldlet(existing, meta_path)
_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,
picked_worldlet(_host_worldlet))
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)
_worldlet_pick = _worldlet_picker(v)
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("%s · seed %d · played %dh %02dm · %s" % [Terrain.worldlet_name(String(w.get("worldlet", "valley"))),
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, "worldlet": picked_worldlet(_worldlet_pick)}))
# --- worldlets (docs/WORLDLETS_DESIGN.md, W1) -----------------------------------------
const Terrain = preload("res://scripts/world/terrain.gd")
var _worldlet_pick: OptionButton
var _host_worldlet: OptionButton
## "Where": the worldlets a new world can start in, with the chosen one's
## description under it. (Only the Valley today: the others are reached on
## the journey, so the list is short and the picker sits still.)
func _worldlet_picker(v: VBoxContainer) -> OptionButton:
var starts: Array = offered_worldlets(OS.is_debug_build())
v.add_child(UITheme.label("Where", 15, UITheme.TEXT_DIM))
var pick := OptionButton.new()
pick.custom_minimum_size = Vector2(360, 36)
pick.focus_mode = Control.FOCUS_NONE
for w in starts:
pick.add_item(String(w.name))
pick.set_item_metadata(pick.item_count - 1, String(w.id))
pick.disabled = starts.size() < 2
v.add_child(pick)
var about := UITheme.label(String(starts[0].description) if not starts.is_empty() else "", 13, UITheme.TEXT_DIM)
about.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
about.custom_minimum_size = Vector2(360, 0)
v.add_child(about)
pick.item_selected.connect(func(i: int):
about.text = String(Terrain.load_worldlet(String(pick.get_item_metadata(i))).get("description", "")))
return pick
## The worldlets offered for a new world: the starts, and (running from the
## source, `debug`) the rest too, marked for testing, so a worldlet can be
## tried before the journey reaches it. A release build offers only the starts.
static func offered_worldlets(debug: bool) -> Array:
var out: Array = Terrain.starting_worldlets()
if debug:
for w in Terrain.worldlets():
if not w.start:
var t: Dictionary = w.duplicate()
t.name = "%s (testing)" % w.name
out.append(t)
return out
## The Host page's "Where": a hosted island keeps its worldlet, like its
## seed, so once it exists the picker shows that one and stays put.
func _show_host_worldlet(existing: bool, meta_path: String) -> void:
var id := "valley"
if existing:
var meta: Variant = JSON.parse_string(FileAccess.get_file_as_string(meta_path))
if meta is Dictionary:
id = String(meta.get("worldlet", "valley"))
var i := select_worldlet(_host_worldlet, id)
if i >= 0:
_host_worldlet.item_selected.emit(i)
_host_worldlet.disabled = existing or _host_worldlet.item_count < 2
## Selects worldlet `id` in `pick`, adding it if it isn't a starting one
## (an island made somewhere else). Returns its index, or -1.
static func select_worldlet(pick: OptionButton, id: String) -> int:
for i in pick.item_count:
if String(pick.get_item_metadata(i)) == id:
pick.select(i)
return i
if Terrain.load_worldlet(id).is_empty():
return -1
pick.add_item(Terrain.worldlet_name(id))
pick.set_item_metadata(pick.item_count - 1, id)
pick.select(pick.item_count - 1)
return pick.item_count - 1
## The id of the worldlet chosen in `pick` (the Valley if nothing is).
static func picked_worldlet(pick: OptionButton) -> String:
if pick == null or pick.item_count == 0 or pick.selected < 0:
return "valley"
return String(pick.get_item_metadata(pick.selected))
## 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 ""