diff --git a/scripts/editor/ui/editor_toolbar.gd b/scripts/editor/ui/editor_toolbar.gd index 5325bb0..3575948 100644 --- a/scripts/editor/ui/editor_toolbar.gd +++ b/scripts/editor/ui/editor_toolbar.gd @@ -8,11 +8,11 @@ signal tool_selected(tool_id: String) signal undo_pressed signal redo_pressed -const TOOL_LABELS := { - "select": "Select", - "brush": "Brush", - "object_place": "Place", - "erase": "Erase", +const TOOL_CONFIG := { + "brush": {"label": "Brush (B)", "tooltip": "Paint solid ground tiles (drag to paint; 0-9 for tile types)"}, + "select": {"label": "Select (V)", "tooltip": "Select and inspect placed objects"}, + "object_place": {"label": "Place (P)", "tooltip": "Place objects from the Palette onto the grid"}, + "erase": {"label": "Erase (X)", "tooltip": "Erase tiles or objects (drag to erase)"}, } var _tool_buttons: Dictionary = {} @@ -25,9 +25,11 @@ func _init() -> void: func _ready() -> void: - for tool_id in TOOL_LABELS: + for tool_id in TOOL_CONFIG: var btn := Button.new() - btn.text = TOOL_LABELS[tool_id] + var cfg: Dictionary = TOOL_CONFIG[tool_id] + btn.text = cfg["label"] + btn.tooltip_text = cfg["tooltip"] btn.toggle_mode = true btn.pressed.connect(_on_tool_button_pressed.bind(tool_id)) add_child(btn) @@ -36,12 +38,16 @@ func _ready() -> void: add_child(VSeparator.new()) _undo_button = Button.new() - _undo_button.text = "Undo" + _undo_button.text = "Undo (Ctrl+Z)" + _undo_button.tooltip_text = "Undo last action" + _undo_button.disabled = true _undo_button.pressed.connect(func(): undo_pressed.emit()) add_child(_undo_button) _redo_button = Button.new() - _redo_button.text = "Redo" + _redo_button.text = "Redo (Ctrl+Y)" + _redo_button.tooltip_text = "Redo last undone action" + _redo_button.disabled = true _redo_button.pressed.connect(func(): redo_pressed.emit()) add_child(_redo_button) @@ -54,7 +60,15 @@ func _on_tool_button_pressed(tool_id: String) -> void: ## Reflect the active tool in the button states without re-emitting the signal. func set_active_tool(tool_id: String) -> void: for id in _tool_buttons: - _tool_buttons[id].button_pressed = (id == tool_id) + var btn: Button = _tool_buttons[id] + var is_active: bool = (str(id) == tool_id) + btn.button_pressed = is_active + if is_active: + btn.add_theme_color_override("font_color", Color(1.0, 0.95, 0.3)) + btn.add_theme_color_override("font_pressed_color", Color(1.0, 0.95, 0.3)) + else: + btn.remove_theme_color_override("font_color") + btn.remove_theme_color_override("font_pressed_color") ## Reflect whether undo/redo currently have anything to do. diff --git a/scripts/editor/ui/editor_viewport.gd b/scripts/editor/ui/editor_viewport.gd index becfbb9..5f390a0 100644 --- a/scripts/editor/ui/editor_viewport.gd +++ b/scripts/editor/ui/editor_viewport.gd @@ -26,6 +26,12 @@ var pending_catalog_id: String = "" ## Tile id the next "brush" click paints. Cycled with number keys 0-9. var current_tile_id: int = 1 +var _hover_tx: int = -1 +var _hover_ty: int = -1 +var _mouse_down: bool = false +var _last_drag_tx: int = -1 +var _last_drag_ty: int = -1 + const TILE_EMPTY_COLOR := Color(0.15, 0.15, 0.17) const TILE_SOLID_COLOR := Color(0.35, 0.55, 0.35) const GRID_COLOR := Color(0, 0, 0, 0.25) @@ -39,6 +45,16 @@ func _init() -> void: focus_mode = Control.FOCUS_ALL +func _notification(what: int) -> void: + if what == NOTIFICATION_MOUSE_EXIT: + _hover_tx = -1 + _hover_ty = -1 + _mouse_down = false + _last_drag_tx = -1 + _last_drag_ty = -1 + queue_redraw() + + func setup(p_core: EditorCore, p_layer_idx: int = 0) -> void: core = p_core layer_idx = p_layer_idx @@ -76,11 +92,32 @@ func _draw() -> void: for y in range(layer.height + 1): draw_line(Vector2(0, y * cell_size), Vector2(layer.width * cell_size, y * cell_size), GRID_COLOR) + # Hover highlight over current cell + if _hover_tx >= 0 and _hover_tx < layer.width and _hover_ty >= 0 and _hover_ty < layer.height: + var h_rect := Rect2(_hover_tx * cell_size, _hover_ty * cell_size, cell_size, cell_size) + draw_rect(h_rect, Color(1.0, 1.0, 1.0, 0.2), true) + draw_rect(h_rect, Color(1.0, 0.95, 0.3, 0.85), false, 2.0) + if core.tools.get_active_tool_id() == "object_place" and pending_catalog_id != "": + var ghost_pos := Vector2((_hover_tx + 0.5) * cell_size, (_hover_ty + 0.5) * cell_size) + draw_circle(ghost_pos, cell_size * 0.35, Color(1.0, 0.8, 0.2, 0.5)) + + var default_font := ThemeDB.fallback_font for obj in core.model.payload.objects: var center := Vector2(obj.position.x * cell_size, obj.position.y * cell_size) var is_selected: bool = core.selection.get_selected() == obj.id var color := OBJECT_SELECTED_COLOR if is_selected else OBJECT_COLOR - draw_circle(center, cell_size * 0.3, color) + var r := cell_size * 0.38 + draw_circle(center, r, color) + draw_arc(center, r, 0.0, TAU, 16, Color(0, 0, 0, 0.85), 1.5) + if is_selected: + draw_arc(center, r + 3.0, 0.0, TAU, 16, Color(1, 1, 1, 0.95), 2.0) + + var display_name: String = "" + if obj.properties is Dictionary and obj.properties.has("display_name"): + display_name = str(obj.properties["display_name"]) + if display_name == "": + display_name = obj.id + draw_string(default_font, center + Vector2(-r * 1.5, -r - 2), display_name.left(6), HORIZONTAL_ALIGNMENT_CENTER, int(r * 3.0), 10, Color(1, 1, 1, 0.9)) func _gui_input(event: InputEvent) -> void: @@ -88,9 +125,27 @@ func _gui_input(event: InputEvent) -> void: return if event is InputEventMouseButton: var mb := event as InputEventMouseButton - if mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT: - _handle_click(mb.position) - accept_event() + if mb.button_index == MOUSE_BUTTON_LEFT: + _mouse_down = mb.pressed + if mb.pressed: + _handle_click(mb.position) + accept_event() + else: + _last_drag_tx = -1 + _last_drag_ty = -1 + elif event is InputEventMouseMotion: + var mm := event as InputEventMouseMotion + var tx := int(floor(mm.position.x / cell_size)) + var ty := int(floor(mm.position.y / cell_size)) + if tx != _hover_tx or ty != _hover_ty: + _hover_tx = tx + _hover_ty = ty + queue_redraw() + if _mouse_down and (tx != _last_drag_tx or ty != _last_drag_ty): + var active_tool := core.tools.get_active_tool_id() + if active_tool in ["brush", "erase"]: + _handle_click(mm.position) + accept_event() elif event is InputEventKey: var key := event as InputEventKey if key.pressed and not key.echo: @@ -105,6 +160,8 @@ func _handle_key(key: InputEventKey) -> void: func _handle_click(local_pos: Vector2) -> void: var tx := int(floor(local_pos.x / cell_size)) var ty := int(floor(local_pos.y / cell_size)) + _last_drag_tx = tx + _last_drag_ty = ty match core.tools.get_active_tool_id(): "brush": @@ -125,6 +182,7 @@ func _handle_click(local_pos: Vector2) -> void: queue_redraw() "select": core.select_object(_object_at(tx, ty)) + edit_applied.emit() queue_redraw() diff --git a/scripts/editor/ui/in_game_editor.gd b/scripts/editor/ui/in_game_editor.gd index 615be19..2db2747 100644 --- a/scripts/editor/ui/in_game_editor.gd +++ b/scripts/editor/ui/in_game_editor.gd @@ -34,6 +34,7 @@ var _viewport: EditorViewport var _close_button: Button var _save_button: Button var _save_status_label: Label +var _instruction_label: Label ## Save-path state (world_persistence_impl.md §8.1/§8.2), set via ## `setup_persistence()`. `_save_service == null` means there is nothing to @@ -66,11 +67,16 @@ func setup_persistence(save_service: ChunkSaveService, world_id: String, room_sl _base_revision = base_revision if _save_button != null: _save_button.disabled = false + _save_button.tooltip_text = "Save chunk changes to backend (Ctrl+S)" func _ready() -> void: _build_ui() _refresh_history_buttons() + if core.tools.get_active_tool_id() == "select" and core.model.payload.objects.is_empty(): + core.tools.set_active_tool("brush") + _toolbar.set_active_tool("brush") + _update_instruction() func _build_ui() -> void: @@ -98,14 +104,21 @@ 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.pressed.connect(_on_save_pressed) top_bar.add_child(_save_button) _close_button = Button.new() - _close_button.text = "Close (F2)" + _close_button.text = "Close (F2 / Esc)" + _close_button.tooltip_text = "Exit build mode and return to game" _close_button.pressed.connect(_on_close_pressed) top_bar.add_child(_close_button) + _instruction_label = Label.new() + _instruction_label.add_theme_font_size_override("font_size", 13) + _instruction_label.add_theme_color_override("font_color", Color(0.75, 0.92, 1.0)) + root_vbox.add_child(_instruction_label) + _save_status_label = Label.new() _save_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD _save_status_label.visible = false @@ -124,13 +137,15 @@ func _build_ui() -> void: viewport_scroll.add_child(_viewport) var sidebar := VBoxContainer.new() - sidebar.custom_minimum_size = Vector2(240, 0) + sidebar.custom_minimum_size = Vector2(260, 0) body.add_child(sidebar) _palette = PalettePanel.new() + _palette.size_flags_vertical = Control.SIZE_EXPAND_FILL sidebar.add_child(_palette) _inspector = InspectorPanel.new() + _inspector.size_flags_vertical = Control.SIZE_EXPAND_FILL sidebar.add_child(_inspector) _viewport.setup(core, 0) @@ -166,16 +181,34 @@ func _unhandled_key_input(event: InputEvent) -> void: core.delete_object(core.selection.get_selected()) _on_edit_applied() get_viewport().set_input_as_handled() + elif key.keycode == KEY_B and not key.ctrl_pressed: + _on_tool_selected("brush") + _toolbar.set_active_tool("brush") + get_viewport().set_input_as_handled() + elif key.keycode == KEY_V and not key.ctrl_pressed: + _on_tool_selected("select") + _toolbar.set_active_tool("select") + get_viewport().set_input_as_handled() + elif key.keycode == KEY_P and not key.ctrl_pressed: + _on_tool_selected("object_place") + _toolbar.set_active_tool("object_place") + get_viewport().set_input_as_handled() + elif key.keycode == KEY_X and not key.ctrl_pressed: + _on_tool_selected("erase") + _toolbar.set_active_tool("erase") + get_viewport().set_input_as_handled() func _on_tool_selected(tool_id: String) -> void: core.tools.set_active_tool(tool_id) + _update_instruction() func _on_palette_object_selected(catalog_id: String) -> void: _viewport.pending_catalog_id = catalog_id core.tools.set_active_tool("object_place") _toolbar.set_active_tool("object_place") + _update_instruction() func _on_inspector_delete_requested(object_id: String) -> void: @@ -197,10 +230,45 @@ func _on_edit_applied() -> void: _viewport.queue_redraw() _inspector.show_object(core.selection.get_selected()) _refresh_history_buttons() + _update_instruction() func _on_selection_changed(object_id: String) -> void: _inspector.show_object(object_id) + _update_instruction() + + +func _update_instruction() -> void: + if _instruction_label == null: + return + var active_id := core.tools.get_active_tool_id() + match active_id: + "brush": + _instruction_label.text = "Tool: [ Brush ] — Left-click and drag on grid squares to paint ground tiles. (Keys 0-9 pick tile type)" + "object_place": + var name := _viewport.pending_catalog_id + if core.catalog != null: + var e := core.catalog.resolve(name) + if e.has("display_name"): + name = str(e["display_name"]) + if name == "": + _instruction_label.text = "Tool: [ Place ] — Select an item in the Palette on the right, then click on the grid squares to place." + else: + _instruction_label.text = "Tool: [ Place: %s ] — Click any grid square to place '%s'." % [name, name] + "select": + if core.selection.has_selection(): + var sel_id := core.selection.get_selected() + var obj := core.model.find_object(sel_id) + var dname := sel_id + if obj != null and obj.properties is Dictionary and obj.properties.has("display_name"): + dname = "%s (%s)" % [obj.properties["display_name"], sel_id] + _instruction_label.text = "Tool: [ Select ] — Selected '%s'. Edit in the Inspector on the right, or press Delete to remove." % dname + else: + _instruction_label.text = "Tool: [ Select ] — Click on a placed object on the grid to inspect it, or switch to Brush/Place to build." + "erase": + _instruction_label.text = "Tool: [ Erase ] — Left-click and drag on grid squares to erase painted tiles or delete objects." + _: + _instruction_label.text = "Tool: [ %s ]" % active_id func _refresh_history_buttons() -> void: diff --git a/scripts/editor/ui/inspector_panel.gd b/scripts/editor/ui/inspector_panel.gd index 44c0753..b33f9fd 100644 --- a/scripts/editor/ui/inspector_panel.gd +++ b/scripts/editor/ui/inspector_panel.gd @@ -37,11 +37,14 @@ var _fields_box: VBoxContainer func _init() -> void: name = "InspectorPanel" + size_flags_vertical = Control.SIZE_EXPAND_FILL func _ready() -> void: var header := Label.new() header.text = "Inspector" + header.add_theme_font_size_override("font_size", 15) + header.add_theme_color_override("font_color", Color(1, 0.95, 0.4)) add_child(header) _empty_label = Label.new() diff --git a/scripts/editor/ui/palette_panel.gd b/scripts/editor/ui/palette_panel.gd index 034f54e..fce678f 100644 --- a/scripts/editor/ui/palette_panel.gd +++ b/scripts/editor/ui/palette_panel.gd @@ -14,15 +14,26 @@ var _ids: Array[String] = [] func _init() -> void: name = "PalettePanel" + size_flags_vertical = Control.SIZE_EXPAND_FILL func _ready() -> void: var header := Label.new() header.text = "Palette" + header.add_theme_font_size_override("font_size", 15) + header.add_theme_color_override("font_color", Color(1, 0.95, 0.4)) add_child(header) + var hint := Label.new() + hint.text = "Click an item to place on the grid:" + hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + hint.add_theme_font_size_override("font_size", 11) + hint.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + add_child(hint) + _list = ItemList.new() _list.size_flags_vertical = Control.SIZE_EXPAND_FILL + _list.custom_minimum_size = Vector2(0, 180) _list.item_selected.connect(_on_item_selected) add_child(_list) diff --git a/scripts/game.gd b/scripts/game.gd index 8349a47..54112ef 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -85,7 +85,11 @@ func _toggle_build_mode() -> void: func _open_build_mode() -> void: - var model := ChunkEditorModel.create_empty(32, 32, str(Session.world_id), "main") + var world_id_str := str(Session.world_id) if Session.world_id != null else "0" + var room_slug := "main" + var coord := Vector3i.ZERO + + var model := ChunkEditorModel.create_empty(32, 32, world_id_str, room_slug, coord) var catalog := EditorObjectCatalog.new() var err := catalog.load_catalog() if err != OK: @@ -93,6 +97,13 @@ func _open_build_mode() -> void: var core := EditorCore.new(model, catalog) _build_editor = InGameEditor.new(core) + if int(world_id_str) > 0: + var save_service := ChunkSaveService.new() + save_service.world_id = world_id_str + save_service.room_slug = room_slug + add_child(save_service) + _build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, 0) + _build_editor.closed.connect(_close_build_mode) add_child(_build_editor) @@ -109,6 +120,9 @@ func _close_build_mode() -> void: if _build_editor != null and is_instance_valid(_build_editor): _build_editor.queue_free() _build_editor = null + for child in get_children(): + if child is ChunkSaveService: + child.queue_free() _player.set_physics_process(true) _player.set_process_unhandled_input(true) _player._refresh_mouse_mode() @@ -210,19 +224,18 @@ 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)) + 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", 6) - lbl.add_theme_font_size_override("font_size", 20) - # Full-width strip pinned to the TOP-left. (A bare preset + .position on a - # zero-size Label collapses the rect to negative width and nothing draws -- - # which is why this never showed; explicit offsets fix it.) - lbl.set_anchors_preset(Control.PRESET_TOP_WIDE) - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + 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 = -12 - lbl.offset_top = 8 - lbl.offset_bottom = 40 + lbl.offset_right = -16 + lbl.offset_top = -32 + lbl.offset_bottom = -8 lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE layer.add_child(lbl)