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) # The journey always begins in the Valley, so a new world has no "Where" # (only with --dev-worldlets, to try the others). if dev_worldlets(): _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(dev_worldlets()) 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 ## Started with `-- --dev-worldlets`: every worldlet can be picked, for ## trying one before the journey reaches it. static func dev_worldlets() -> bool: return "--dev-worldlets" in OS.get_cmdline_user_args() ## The worldlets offered for a new world or island: the starts, and (`dev`) ## the rest too, marked for testing. static func offered_worldlets(dev: bool) -> Array: var out: Array = Terrain.starting_worldlets() if dev: 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 ""