340 lines
12 KiB
GDScript
340 lines
12 KiB
GDScript
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
|
|
## Elevation offset for placement and hotkey nudging (Q / E).
|
|
var current_elevation_offset: float = 0.0
|
|
## Whether to align placed object to surface normal slope.
|
|
var align_surface_normal: bool = false
|
|
|
|
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))
|
|
|
|
# Draw motion waypoint path & gizmos for selected object
|
|
var sel_id := core.selection.get_selected()
|
|
if sel_id != "":
|
|
var sel_obj := core.model.find_object(sel_id)
|
|
if sel_obj != null and sel_obj.properties is Dictionary and sel_obj.properties.has("motion"):
|
|
var m_cfg = sel_obj.properties["motion"]
|
|
if m_cfg is Dictionary and m_cfg.has("waypoints") and m_cfg["waypoints"] is Array:
|
|
var wps: Array = m_cfg["waypoints"]
|
|
if not wps.is_empty():
|
|
var prev_pt := Vector2(sel_obj.position.x * cell_size, sel_obj.position.y * cell_size)
|
|
for i in range(wps.size()):
|
|
var wp_val = wps[i]
|
|
var wp_pos := Vector2.ZERO
|
|
if wp_val is Array and wp_val.size() >= 2:
|
|
wp_pos = Vector2(float(wp_val[0]) * cell_size, float(wp_val[1]) * cell_size)
|
|
elif wp_val is Vector3:
|
|
wp_pos = Vector2(wp_val.x * cell_size, wp_val.y * cell_size)
|
|
|
|
draw_line(prev_pt, wp_pos, Color(0.2, 0.85, 1.0, 0.8), 2.0)
|
|
var wr := cell_size * 0.28
|
|
draw_circle(wp_pos, wr, Color(0.1, 0.6, 0.9, 0.85))
|
|
draw_arc(wp_pos, wr, 0.0, TAU, 16, Color(1, 1, 1, 0.9), 1.5)
|
|
draw_string(default_font, wp_pos + Vector2(-4, 4), str(i + 1), HORIZONTAL_ALIGNMENT_CENTER, 8, 10, Color(1, 1, 1))
|
|
prev_pt = wp_pos
|
|
|
|
|
|
|
|
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
|
|
elif key.keycode == KEY_E:
|
|
current_elevation_offset += 0.5
|
|
var sel := core.selection.get_selected()
|
|
if sel != "":
|
|
core.adjust_object_elevation(sel, 0.5)
|
|
edit_applied.emit()
|
|
queue_redraw()
|
|
elif key.keycode == KEY_Q:
|
|
current_elevation_offset = maxf(0.0, current_elevation_offset - 0.5)
|
|
var sel := core.selection.get_selected()
|
|
if sel != "":
|
|
core.adjust_object_elevation(sel, -0.5)
|
|
edit_applied.emit()
|
|
queue_redraw()
|
|
elif key.keycode == KEY_END:
|
|
var sel := core.selection.get_selected()
|
|
if sel != "":
|
|
if core.drop_object_to_surface(sel):
|
|
edit_applied.emit()
|
|
queue_redraw()
|
|
elif key.keycode == KEY_R:
|
|
align_surface_normal = not align_surface_normal
|
|
elif key.keycode == KEY_W:
|
|
var sel := core.selection.get_selected()
|
|
if sel != "" and _hover_tx >= 0 and _hover_ty >= 0:
|
|
var obj := core.model.find_object(sel)
|
|
if obj != null:
|
|
var motion_dict = obj.properties.get("motion", {"motion_type": "waypoint", "waypoints": []})
|
|
if not (motion_dict is Dictionary):
|
|
motion_dict = {"motion_type": "waypoint", "waypoints": []}
|
|
var wps = motion_dict.get("waypoints", [])
|
|
wps.append([float(_hover_tx), float(_hover_ty), obj.position.z])
|
|
motion_dict["motion_type"] = "waypoint"
|
|
motion_dict["waypoints"] = wps
|
|
core.set_object_property(sel, "motion", motion_dict)
|
|
edit_applied.emit()
|
|
queue_redraw()
|
|
elif key.keycode == KEY_BACKSPACE:
|
|
var sel := core.selection.get_selected()
|
|
if sel != "":
|
|
var obj := core.model.find_object(sel)
|
|
if obj != null and obj.properties is Dictionary and obj.properties.has("motion"):
|
|
var motion_dict = obj.properties["motion"]
|
|
if motion_dict is Dictionary and motion_dict.has("waypoints") and motion_dict["waypoints"] is Array:
|
|
var wps: Array = motion_dict["waypoints"]
|
|
if not wps.is_empty():
|
|
wps.pop_back()
|
|
motion_dict["waypoints"] = wps
|
|
core.set_object_property(sel, "motion", motion_dict)
|
|
edit_applied.emit()
|
|
queue_redraw()
|
|
|
|
|
|
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 existing_hit := _highest_object_at(tx, ty)
|
|
var new_id: String = ""
|
|
if existing_hit != "":
|
|
new_id = core.place_object_stacked(
|
|
pending_catalog_id,
|
|
existing_hit,
|
|
Vector3(tx + 0.5, ty + 0.5, 0.0),
|
|
Vector3(0, 0, 1),
|
|
current_elevation_offset,
|
|
align_surface_normal
|
|
)
|
|
else:
|
|
new_id = core.place_object(
|
|
pending_catalog_id,
|
|
Vector3(tx + 0.5, ty + 0.5, current_elevation_offset)
|
|
)
|
|
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 ""
|
|
|
|
|
|
func _highest_object_at(tx: int, ty: int) -> String:
|
|
var best_id := ""
|
|
var max_z: float = -1.0
|
|
for obj in core.model.payload.objects:
|
|
if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty:
|
|
if obj.position.z > max_z:
|
|
max_z = obj.position.z
|
|
best_id = obj.id
|
|
return best_id
|
|
|
|
|
|
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
|
|
if data is Dictionary and data.get("type") == "model_placement":
|
|
return true
|
|
return false
|
|
|
|
|
|
func _drop_data(at_position: Vector2, data: Variant) -> void:
|
|
if not _can_drop_data(at_position, data):
|
|
return
|
|
if core == null:
|
|
return
|
|
var dict: Dictionary = data as Dictionary
|
|
var cat_id: String = dict.get("catalog_id", "")
|
|
if cat_id == "":
|
|
cat_id = dict.get("model_name", "object").to_lower().replace(" ", "_")
|
|
|
|
var tx := int(floor(at_position.x / cell_size))
|
|
var ty := int(floor(at_position.y / cell_size))
|
|
var existing_hit := _highest_object_at(tx, ty)
|
|
var new_id := ""
|
|
if existing_hit != "":
|
|
new_id = core.place_object_stacked(
|
|
cat_id,
|
|
existing_hit,
|
|
Vector3(tx + 0.5, ty + 0.5, 0.0),
|
|
Vector3(0, 0, 1),
|
|
current_elevation_offset,
|
|
align_surface_normal
|
|
)
|
|
else:
|
|
new_id = core.place_object(
|
|
cat_id,
|
|
Vector3(tx + 0.5, ty + 0.5, current_elevation_offset)
|
|
)
|
|
if new_id != "":
|
|
edit_applied.emit()
|
|
queue_redraw()
|