diff --git a/project.godot b/project.godot index a1fabec..c64bfa0 100644 --- a/project.godot +++ b/project.godot @@ -22,6 +22,7 @@ Session="*res://scripts/session.gd" window/size/viewport_width=1920 window/size/viewport_height=1080 window/stretch/mode="canvas_items" +window/stretch/aspect="keep" [input] diff --git a/scenes/world_list.tscn b/scenes/world_list.tscn index 7b4885d..62701a4 100644 --- a/scenes/world_list.tscn +++ b/scenes/world_list.tscn @@ -36,6 +36,11 @@ horizontal_alignment = 1 size_flags_horizontal = 3 theme_override_font_sizes/font_size = 28 +[node name="CreateButton" type="Button" parent="VBoxContainer/HeaderHBox"] +layout_mode = 3 +text = "+ Create World" +size_flags_horizontal = 0 + [node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"] layout_mode = 3 text = "Refresh" @@ -75,4 +80,5 @@ horizontal_alignment = 1 theme_override_font_sizes/font_size = 18 [connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"] +[connection signal="pressed" from="VBoxContainer/HeaderHBox/CreateButton" to="." method="_on_create_button_pressed"] [connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"] diff --git a/scripts/api_client.gd b/scripts/api_client.gd index f7d76d1..bb77209 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -247,6 +247,19 @@ func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dict return await request(HTTPClient.METHOD_GET, path, "", "application/json", true) +## Create a new world owned by the current user (requires auth). +## On success, result.data is the created World object containing {"id", "name", ...}. +func create_world(world_name: String, description: String = "", visibility: String = "public", category: String = "Building", tags: Array = []) -> Dictionary: + var payload: Dictionary = { + "name": world_name, + "description": description, + "visibility": visibility, + "category": category, + "tags": tags, + } + return await request(HTTPClient.METHOD_POST, "/api/worlds/", JSON.stringify(payload), "application/json", true) + + ## Ping the backend health endpoint (no auth). func health_check() -> Dictionary: return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false) diff --git a/scripts/editor/ui/in_game_editor.gd b/scripts/editor/ui/in_game_editor.gd index 2db2747..e600940 100644 --- a/scripts/editor/ui/in_game_editor.gd +++ b/scripts/editor/ui/in_game_editor.gd @@ -33,8 +33,11 @@ var _inspector: InspectorPanel var _viewport: EditorViewport var _close_button: Button var _save_button: Button +var _save_as_button: Button var _save_status_label: Label var _instruction_label: Label +var _save_as_modal: Control = null + ## Save-path state (world_persistence_impl.md §8.1/§8.2), set via ## `setup_persistence()`. `_save_service == null` means there is nothing to @@ -103,11 +106,17 @@ func _build_ui() -> void: _save_button = Button.new() _save_button.text = "Save (Ctrl+S)" - _save_button.disabled = (_save_service == null) - _save_button.tooltip_text = "Save changes to server (Ctrl+S)" if _save_service != null else "Saving not available in scratch mode" + _save_button.tooltip_text = "Save changes to server (Ctrl+S)" _save_button.pressed.connect(_on_save_pressed) top_bar.add_child(_save_button) + _save_as_button = Button.new() + _save_as_button.text = "Save as New World" + _save_as_button.tooltip_text = "Save this creation as a new world you own on the server" + _save_as_button.pressed.connect(_on_save_as_pressed) + top_bar.add_child(_save_as_button) + + _close_button = Button.new() _close_button.text = "Close (F2 / Esc)" _close_button.tooltip_text = "Exit build mode and return to game" @@ -285,8 +294,13 @@ func _on_close_pressed() -> void: ## (§8.2/§5.4) -- this just reflects the outcome and, per §6 D4, surfaces ## the `live_session` flag as a warning instead of ignoring it. func _on_save_pressed() -> void: - if _save_service == null or _saving: + if _saving: return + if _save_service == null: + _set_save_status("No world selected to save to. Choose 'Save as New World'.", Color(1.0, 0.8, 0.25)) + _show_save_as_modal() + return + _saving = true _save_button.disabled = true _set_save_status("Saving…", Color(0.85, 0.85, 0.85)) @@ -307,7 +321,174 @@ func _on_save_pressed() -> void: else: _set_save_status(msg, Color(0.55, 1.0, 0.55)) else: - _set_save_status("Save failed: %s" % str(result.get("detail", "unknown error")), Color(1.0, 0.45, 0.45)) + var status_code := int(result.get("status", 0)) + var detail := str(result.get("detail", "unknown error")) + if status_code == 403 or "owner" in detail.to_lower(): + _set_save_status("Save failed: You do not own world %s. Use 'Save as New World' to save your work." % _world_id, Color(1.0, 0.45, 0.45)) + _show_save_as_modal() + else: + _set_save_status("Save failed: %s" % detail, Color(1.0, 0.45, 0.45)) + + +func _on_save_as_pressed() -> void: + _show_save_as_modal() + + +func _show_save_as_modal() -> void: + if _save_as_modal != null and is_instance_valid(_save_as_modal): + _save_as_modal.queue_free() + + var overlay := ColorRect.new() + overlay.name = "SaveAsOverlay" + overlay.color = Color(0, 0, 0, 0.75) + overlay.set_anchors_preset(Control.PRESET_FULL_RECT) + overlay.mouse_filter = Control.MOUSE_FILTER_STOP + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + overlay.add_child(center) + + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(460, 0) + var style := StyleBoxFlat.new() + style.bg_color = Color(0.18, 0.18, 0.22, 1.0) + style.border_color = Color(0.4, 0.6, 0.9, 1.0) + style.set_border_width_all(2) + style.set_corner_radius_all(8) + style.content_margin_left = 24 + style.content_margin_right = 24 + style.content_margin_top = 20 + style.content_margin_bottom = 20 + panel.add_theme_stylebox_override("panel", style) + center.add_child(panel) + + var form := VBoxContainer.new() + form.add_theme_constant_override("separation", 12) + panel.add_child(form) + + var title := Label.new() + title.text = "Save as New World" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 20) + form.add_child(title) + + var hint := Label.new() + hint.text = "Creates a new world owned by you and saves your current build into it." + hint.autowrap_mode = TextServer.AUTOWRAP_WORD + hint.add_theme_font_size_override("font_size", 13) + hint.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75)) + form.add_child(hint) + + var name_lbl := Label.new() + name_lbl.text = "World Name:" + name_lbl.add_theme_font_size_override("font_size", 14) + form.add_child(name_lbl) + + var name_input := LineEdit.new() + name_input.placeholder_text = "Enter world name..." + name_input.text = "My Creation" if (_world_id.is_empty() or _world_id == "0") else "Remix of World %s" % _world_id + form.add_child(name_input) + + var desc_lbl := Label.new() + desc_lbl.text = "Description:" + desc_lbl.add_theme_font_size_override("font_size", 14) + form.add_child(desc_lbl) + + var desc_input := LineEdit.new() + desc_input.placeholder_text = "Describe your world..." + desc_input.text = "Created in editor" + form.add_child(desc_input) + + var err_label := Label.new() + err_label.add_theme_font_size_override("font_size", 13) + err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + err_label.visible = false + form.add_child(err_label) + + var btn_row := HBoxContainer.new() + btn_row.alignment = BoxContainer.ALIGNMENT_END + btn_row.add_theme_constant_override("separation", 10) + form.add_child(btn_row) + + var cancel_btn := Button.new() + cancel_btn.text = "Cancel" + cancel_btn.pressed.connect(func(): overlay.queue_free()) + btn_row.add_child(cancel_btn) + + var save_btn := Button.new() + save_btn.text = "Save New World" + btn_row.add_child(save_btn) + + save_btn.pressed.connect(func() -> void: + var wname := name_input.text.strip_edges() + if wname.is_empty(): + err_label.text = "World name cannot be empty." + err_label.visible = true + return + save_btn.disabled = true + cancel_btn.disabled = true + err_label.text = "Creating world..." + err_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8)) + err_label.visible = true + + var api := _api_client() + if api == null: + save_btn.disabled = false + cancel_btn.disabled = false + err_label.text = "API client not available." + err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + return + + var res: Dictionary = await api.create_world(wname, desc_input.text.strip_edges()) + if not res.get("ok", false): + save_btn.disabled = false + cancel_btn.disabled = false + err_label.text = "Failed to create world: %s" % str(res.get("detail", "unknown error")) + err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + return + + var new_world: Dictionary = res.get("data", {}) + var new_id: int = int(new_world.get("id", 0)) + var new_id_str: String = str(new_id) + + err_label.text = "Saving chunk data..." + if _save_service == null: + _save_service = ChunkSaveService.new() + add_child(_save_service) + _save_service.world_id = new_id_str + _save_service.room_slug = "main" + + _world_id = new_id_str + _room_slug = "main" + _coord = Vector3i.ZERO + _base_revision = 0 + var sess := _session() + if sess != null: + sess.world_id = new_id + + core.model.payload.world_id = _world_id + core.model.payload.room_id = _room_slug + core.model.payload.coord = _coord + + + var chunk_res: Dictionary = await _save_service.save_chunk(core.model, _coord, 0) + if not chunk_res.get("ok", false): + save_btn.disabled = false + cancel_btn.disabled = false + err_label.text = "World created (ID: %d) but chunk save failed: %s" % [new_id, str(chunk_res.get("detail", "unknown error"))] + err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + return + + _base_revision = int(chunk_res.get("revision", 1)) + _save_button.disabled = false + _save_button.tooltip_text = "Save chunk changes to backend (Ctrl+S)" + _set_save_status("Saved successfully as new world '%s' (ID: %d, revision %d)!" % [wname, new_id, _base_revision], Color(0.55, 1.0, 0.55)) + overlay.queue_free() + ) + + _save_as_modal = overlay + add_child(overlay) + func _set_save_status(text: String, color: Color) -> void: @@ -316,3 +497,12 @@ func _set_save_status(text: String, color: Color) -> void: _save_status_label.text = text _save_status_label.add_theme_color_override("font_color", color) _save_status_label.visible = true + + +func _api_client() -> Node: + return get_node_or_null("/root/ApiClient") + + +func _session() -> Node: + return get_node_or_null("/root/Session") + diff --git a/scripts/game.gd b/scripts/game.gd index 54112ef..e52e102 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -224,21 +224,22 @@ func _show_build_stamp() -> void: add_child(layer) var lbl := Label.new() lbl.text = "build %s" % _build_label() - lbl.add_theme_color_override("font_color", Color(1, 1, 0.35, 0.85)) - lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1)) - lbl.add_theme_constant_override("outline_size", 4) - lbl.add_theme_font_size_override("font_size", 14) - # Pinned to the BOTTOM-right so it never blocks top menu or editor toolbars. - lbl.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM - lbl.offset_left = 12 - lbl.offset_right = -16 - lbl.offset_top = -32 - lbl.offset_bottom = -8 + lbl.add_theme_color_override("font_color", Color(1, 1, 0.35, 1.0)) + lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1.0)) + lbl.add_theme_constant_override("outline_size", 6) + lbl.add_theme_font_size_override("font_size", 18) lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE layer.add_child(lbl) + var reposition := func() -> void: + if is_instance_valid(lbl) and is_inside_tree(): + var vp := get_viewport().get_visible_rect().size + lbl.position = Vector2(20, vp.y - 40) + lbl.size = Vector2(vp.x - 40, 32) + reposition.call() + get_viewport().size_changed.connect(reposition) + + func _exit_tree() -> void: # Backstop for any exit path that didn't go through _leave_world (e.g. the diff --git a/scripts/world_list.gd b/scripts/world_list.gd index 128086b..d4783c7 100644 --- a/scripts/world_list.gd +++ b/scripts/world_list.gd @@ -12,6 +12,7 @@ extends Control @onready var error_label: Label = $VBoxContainer/ErrorLabel @onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton @onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton +@onready var create_button: Button = $VBoxContainer/HeaderHBox/CreateButton @onready var empty_label: Label = $VBoxContainer/EmptyLabel ## Cached so each world card can decide whether to show "Edit World" @@ -20,16 +21,20 @@ extends Control ## round trip per card. -1 until resolved / on failure, in which case no ## card shows the edit entry point. var _current_user_id: int = -1 +var _create_modal: Control = null func _ready() -> void: refresh_button.disabled = false back_button.disabled = false + create_button.disabled = false loading_label.visible = false error_label.visible = false empty_label.visible = false world_list.visible = false + _show_build_stamp() + var me_result: Dictionary = await ApiClient.get_me() if me_result.ok and me_result.data is Dictionary: _current_user_id = int((me_result.data as Dictionary).get("id", -1)) @@ -37,6 +42,157 @@ func _ready() -> void: _refresh_worlds() + +func _show_build_stamp() -> void: + var lbl := Label.new() + var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ) + var stamp := "dev" + if f != null: + var d = JSON.parse_string(f.get_as_text()) + if d is Dictionary: + stamp = "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")] + lbl.text = "build %s" % stamp + lbl.add_theme_color_override("font_color", Color(1, 1, 0.35, 1.0)) + lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1.0)) + lbl.add_theme_constant_override("outline_size", 6) + lbl.add_theme_font_size_override("font_size", 18) + lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(lbl) + + var reposition := func() -> void: + if is_instance_valid(lbl) and is_inside_tree(): + var vp := get_viewport().get_visible_rect().size + lbl.position = Vector2(20, vp.y - 40) + lbl.size = Vector2(vp.x - 40, 32) + reposition.call() + get_viewport().size_changed.connect(reposition) + + +func _on_create_button_pressed() -> void: + if _create_modal != null and is_instance_valid(_create_modal): + _create_modal.queue_free() + _create_modal = _build_create_world_modal() + add_child(_create_modal) + + +func _build_create_world_modal() -> Control: + var overlay := ColorRect.new() + overlay.name = "CreateWorldOverlay" + overlay.color = Color(0, 0, 0, 0.75) + overlay.set_anchors_preset(Control.PRESET_FULL_RECT) + overlay.mouse_filter = Control.MOUSE_FILTER_STOP + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + overlay.add_child(center) + + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(480, 0) + var style := StyleBoxFlat.new() + style.bg_color = Color(0.18, 0.18, 0.22, 1.0) + style.border_color = Color(0.4, 0.55, 0.8, 1.0) + style.set_border_width_all(2) + style.set_corner_radius_all(8) + style.content_margin_left = 24 + style.content_margin_right = 24 + style.content_margin_top = 20 + style.content_margin_bottom = 20 + panel.add_theme_stylebox_override("panel", style) + center.add_child(panel) + + var form := VBoxContainer.new() + form.add_theme_constant_override("separation", 12) + panel.add_child(form) + + var title := Label.new() + title.text = "Create New World" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 22) + title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + form.add_child(title) + + var name_lbl := Label.new() + name_lbl.text = "World Name:" + name_lbl.add_theme_font_size_override("font_size", 14) + form.add_child(name_lbl) + + var name_input := LineEdit.new() + name_input.placeholder_text = "Enter world name..." + name_input.text = "My New World" + form.add_child(name_input) + + var desc_lbl := Label.new() + desc_lbl.text = "Description:" + desc_lbl.add_theme_font_size_override("font_size", 14) + form.add_child(desc_lbl) + + var desc_input := LineEdit.new() + desc_input.placeholder_text = "Describe your world..." + desc_input.text = "A creative build world" + form.add_child(desc_input) + + var err_label := Label.new() + err_label.add_theme_font_size_override("font_size", 13) + err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + err_label.visible = false + form.add_child(err_label) + + var btn_row := HBoxContainer.new() + btn_row.alignment = BoxContainer.ALIGNMENT_END + btn_row.add_theme_constant_override("separation", 10) + form.add_child(btn_row) + + var cancel_btn := Button.new() + cancel_btn.text = "Cancel" + cancel_btn.pressed.connect(func(): overlay.queue_free()) + btn_row.add_child(cancel_btn) + + var create_btn := Button.new() + create_btn.text = "Create World" + btn_row.add_child(create_btn) + + var create_edit_btn := Button.new() + create_edit_btn.text = "Create & Edit Now" + btn_row.add_child(create_edit_btn) + + var handle_create := func(open_editor: bool) -> void: + var wname := name_input.text.strip_edges() + if wname.is_empty(): + err_label.text = "World name cannot be empty." + err_label.visible = true + return + create_btn.disabled = true + create_edit_btn.disabled = true + cancel_btn.disabled = true + err_label.text = "Creating world..." + err_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8)) + err_label.visible = true + + var res: Dictionary = await ApiClient.create_world(wname, desc_input.text.strip_edges()) + if not res.get("ok", false): + create_btn.disabled = false + create_edit_btn.disabled = false + cancel_btn.disabled = false + err_label.text = "Failed: %s" % str(res.get("detail", "unknown error")) + err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + return + + var new_world: Dictionary = res.get("data", {}) + var new_world_id: int = int(new_world.get("id", 0)) + overlay.queue_free() + + if open_editor and new_world_id > 0: + Session.begin_edit(new_world_id) + get_tree().change_scene_to_file("res://scenes/game.tscn") + else: + _refresh_worlds() + + create_btn.pressed.connect(handle_create.bind(false)) + create_edit_btn.pressed.connect(handle_create.bind(true)) + + return overlay + + func _on_refresh_button_pressed() -> void: _refresh_worlds() @@ -45,6 +201,7 @@ func _on_back_button_pressed() -> void: get_tree().change_scene_to_file("res://scenes/main_menu.tscn") + ## Fetch the world list from the backend and update the UI. func _refresh_worlds() -> void: # Reset UI state @@ -121,13 +278,24 @@ func _create_world_card(world_dict: Dictionary) -> Control: card.add_theme_constant_override("separation", 4) panel.add_child(card) + var owner_id := int(world_dict.get("owner_id", -1)) + var is_owner := (_current_user_id != -1 and owner_id == _current_user_id) + var name_label := Label.new() - name_label.text = world_dict.get("name", "Unnamed World") + var raw_name: String = world_dict.get("name", "Unnamed World") + if is_owner: + name_label.text = "%s ★ [Your World]" % raw_name + name_label.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4, 1.0)) + style.border_color = Color(0.7, 0.6, 0.2, 0.8) + style.set_border_width_all(2) + else: + name_label.text = raw_name name_label.autowrap_mode = TextServer.AUTOWRAP_WORD name_label.size_flags_horizontal = Control.SIZE_FILL name_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN name_label.add_theme_font_size_override("font_size", 18) + var desc_label := Label.new() var description: String = str(world_dict.get("description", "")) desc_label.text = description if description != "" else "No description." @@ -166,8 +334,7 @@ func _create_world_card(world_dict: Dictionary) -> Control: # shown for the world's owner, matching the write-side authorization the # save path itself enforces (§5.3), so a non-owner never sees a button # that would just 403 on the first save. - var owner_id := int(world_dict.get("owner_id", -1)) - if _current_user_id != -1 and owner_id == _current_user_id: + if is_owner: var edit_button := Button.new() edit_button.text = "Edit World" edit_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN