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 _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) _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(_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("main") # 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", "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": 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": _name_edit.text = _unique_name() _seed_edit.text = "" _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() if not worlds.is_empty(): var w: Dictionary = worlds[0] v.add_child(_button("Continue: %s" % w.name, _play.bind(w.id))) 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("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) _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) 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()) _play(Saves.create_world(world_name, seed)) func _play(id: String) -> void: Saves.current_id = id get_tree().change_scene_to_file(WORLD_SCENE)