class_name EditorViewport extends Control ## Custom 2D viewport for the in-game editor (docs/architecture/editor.md ## §7.2 `EditorViewport`). ## ## Deliberately 2D top-down, not a 3D gizmo viewport -- per §8's own risk ## mitigation ("start with a 2D tile editor... matches CHUNK_SIZE_Z=1 ## default; add 3D later"). Renders one tile layer as a grid plus the ## chunk's objects as dots, and turns clicks into calls on `EditorCore` ## routed through whichever tool is active in `ToolRegistry` -- this is UI ## glue, it never mutates `ChunkEditorModel` itself. ## ## Coordinate convention matches the rest of this codebase's tile system ## (scripts/tile_layer_node.gd `_tile_to_world`): chunk-local X/Y are the ## flat ground plane, Z is height (CHUNK_SIZE_Z=1 by default -- see ## scripts/chunk.gd). One grid cell == one world unit == `cell_size` pixels. signal edit_applied var core: EditorCore = null var layer_idx: int = 0 var cell_size: float = 24.0 ## Catalog id the next "object_place" click will place. Set by ## `InGameEditor` from `PalettePanel.object_selected`. 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) const OBJECT_COLOR := Color(0.9, 0.6, 0.2) const OBJECT_SELECTED_COLOR := Color(1.0, 0.95, 0.2) func _init() -> void: name = "EditorViewport" mouse_filter = Control.MOUSE_FILTER_STOP 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 core.selection.selection_changed.connect(func(_id): queue_redraw()) _resize_to_layer() queue_redraw() func _resize_to_layer() -> void: var layer := core.model.get_layer(layer_idx) if layer == null: custom_minimum_size = Vector2(cell_size * 8, cell_size * 8) return custom_minimum_size = Vector2(layer.width * cell_size, layer.height * cell_size) func _draw() -> void: if core == null: return var layer := core.model.get_layer(layer_idx) if layer == null: return for y in range(layer.height): for x in range(layer.width): var tile_id := layer.get_tile(x, y, 0) var color := TILE_EMPTY_COLOR var tdef := layer.get_tile_def(tile_id) if tdef != null and tdef.solid: color = TILE_SOLID_COLOR draw_rect(Rect2(x * cell_size, y * cell_size, cell_size, cell_size), color, true) for x in range(layer.width + 1): draw_line(Vector2(x * cell_size, 0), Vector2(x * cell_size, layer.height * cell_size), GRID_COLOR) 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 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: if core == null: return if event is InputEventMouseButton: var mb := event as InputEventMouseButton 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: _handle_key(key) func _handle_key(key: InputEventKey) -> void: if key.keycode >= KEY_0 and key.keycode <= KEY_9: current_tile_id = key.keycode - KEY_0 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": if core.paint_tile(layer_idx, tx, ty, current_tile_id): edit_applied.emit() queue_redraw() "erase": var hit := _object_at(tx, ty) var changed: bool = core.delete_object(hit) if hit != "" else core.erase_tile(layer_idx, tx, ty) if changed: edit_applied.emit() queue_redraw() "object_place": if pending_catalog_id != "": var new_id := core.place_object(pending_catalog_id, Vector3(tx + 0.5, ty + 0.5, 0.0)) if new_id != "": edit_applied.emit() queue_redraw() "select": core.select_object(_object_at(tx, ty)) edit_applied.emit() queue_redraw() func _object_at(tx: int, ty: int) -> String: for obj in core.model.payload.objects: if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty: return obj.id return ""