@tool extends Node3D class_name GridPlaneManager enum GridType { SQUARE, HEX } signal grid_layout_changed(cells: Dictionary) var _grid_type: GridType = GridType.SQUARE @export var grid_type: GridType = GridType.SQUARE: set(value): if value == _grid_type: return _grid_type = value as GridType _queue_rebuild() get: return _grid_type var _columns: int = 10 @export_range(1, 256, 1) var columns: int: set(value): value = max(value, 1) if value == _columns: return _columns = value _queue_rebuild() get: return _columns var _rows: int = 10 @export_range(1, 256, 1) var rows: int: set(value): value = max(value, 1) if value == _rows: return _rows = value _queue_rebuild() get: return _rows var _cell_size: float = 1.0 @export_range(0.1, 10.0, 0.1) var cell_size: float: set(value): value = max(value, 0.25) if is_equal_approx(value, _cell_size): return _cell_size = value _queue_rebuild() get: return _cell_size var _ground_thickness: float = 0.4 @export_range(0.1, 2.0, 0.05) var ground_thickness: float: set(value): value = clamp(value, 0.1, 2.0) if is_equal_approx(value, _ground_thickness): return _ground_thickness = value _queue_rebuild() get: return _ground_thickness var _ground_padding: float = 20.0 @export_range(0.0, 200.0, 1.0) var ground_padding: float: set(value): value = max(value, 0.0) if is_equal_approx(value, _ground_padding): return _ground_padding = value _update_ground_assets() get: return _ground_padding var _zoom_factor: float = 1.0 var _zoom_min_internal: float = 0.5 var _zoom_max_internal: float = 3.0 var _zoom_step_internal: float = 0.1 @export_range(0.1, 10.0, 0.1) var zoom_min: float = 0.5: set(value): _zoom_min_internal = clamp(value, 0.1, min(_zoom_max_internal, 10.0)) if _zoom_factor < _zoom_min_internal: _zoom_factor = _zoom_min_internal _update_camera_view() _update_ground_assets() get: return _zoom_min_internal @export_range(0.1, 10.0, 0.1) var zoom_max: float = 3.0: set(value): _zoom_max_internal = max(value, _zoom_min_internal) if _zoom_factor > _zoom_max_internal: _zoom_factor = _zoom_max_internal _update_camera_view() _update_ground_assets() get: return _zoom_max_internal @export_range(0.01, 1.0, 0.01) var zoom_step: float = 0.1: set(value): _zoom_step_internal = clamp(value, 0.01, 1.0) get: return _zoom_step_internal var _show_grid_lines: bool = true @export var show_grid_lines: bool: set(value): if value == _show_grid_lines: return _show_grid_lines = value if is_node_ready(): _update_grid_visibility() get: return _show_grid_lines var _grid_line_color: Color = Color(0.2, 0.7, 1.0, 0.65) @export var grid_line_color: Color: set(value): if value == _grid_line_color: return _grid_line_color = value _queue_rebuild() get: return _grid_line_color const _LINE_HEIGHT_OFFSET := 0.02 const _SQRT3 := 1.7320508075688772 @onready var _ground_mesh: MeshInstance3D = %GroundMesh @onready var _ground_collision: CollisionShape3D = %GroundCollision @onready var _grid_lines: MeshInstance3D = %GridLines @onready var _camera: Camera3D = %Camera3D @onready var _hover_highlight: MeshInstance3D = %HoverHighlight var _grid_size: Vector2 = Vector2.ZERO var _cells: Dictionary = {} var _queued_rebuild: bool = false var _hovered_cell: Vector2i = Vector2i(-1, -1) var _hex_center_offset_local: Vector3 = Vector3.ZERO func _ready() -> void: set_process(true) _zoom_factor = clamp(_zoom_factor, _zoom_min_internal, _zoom_max_internal) _queue_rebuild() func get_cells() -> Dictionary: return _cells.duplicate() func get_cell_center(index: Vector2i) -> Vector3: return _cells.get(index, Vector3.ZERO) func cell_to_world(index: Vector2i) -> Vector3: return get_cell_center(index) func world_to_cell(world_position: Vector3, clamp_to_bounds: bool = true) -> Vector2i: var local_position: Vector3 = to_local(world_position) if _grid_type == GridType.HEX: return _world_to_hex_cell(local_position, clamp_to_bounds) return _world_to_square_cell(local_position, clamp_to_bounds) func for_each_cell(callback: Callable) -> void: for index in _cells.keys(): callback.call(index, _cells[index]) func _queue_rebuild() -> void: if _queued_rebuild: return _queued_rebuild = true call_deferred("_process_rebuild_request") func _process_rebuild_request() -> void: _queued_rebuild = false if not is_node_ready(): return _rebuild_grid() func _rebuild_grid() -> void: match _grid_type: GridType.HEX: _generate_hex_grid() _: _generate_square_grid() _update_grid_lines() _update_highlight_mesh() _update_camera_view() _update_ground_assets() emit_signal("grid_layout_changed", _cells) func _update_ground_assets() -> void: if not is_node_ready(): return var required_size := _compute_required_ground_size() if _ground_mesh and _ground_mesh.mesh is PlaneMesh: var plane_mesh := _ground_mesh.mesh as PlaneMesh plane_mesh.size = required_size if _ground_collision and _ground_collision.shape is BoxShape3D: var box := _ground_collision.shape as BoxShape3D box.size = Vector3(required_size.x, _ground_thickness, required_size.y) func _generate_square_grid() -> void: _hex_center_offset_local = Vector3.ZERO _cells.clear() _grid_size = Vector2(_columns * _cell_size, _rows * _cell_size) var start_x := -_grid_size.x * 0.5 + _cell_size * 0.5 var start_z := -_grid_size.y * 0.5 + _cell_size * 0.5 for row in range(_rows): for column in range(_columns): var center := Vector3( start_x + float(column) * _cell_size, 0.0, start_z + float(row) * _cell_size ) _cells[Vector2i(column, row)] = center func _generate_hex_grid() -> void: _cells.clear() if _columns <= 0 or _rows <= 0: _grid_size = Vector2.ZERO return var radius: float = max(_cell_size * 0.5, 0.001) var min_corner_x: float = INF var max_corner_x: float = -INF var min_corner_z: float = INF var max_corner_z: float = -INF for row in range(_rows): for column in range(_columns): var x := radius * 1.5 * float(column) var z := radius * _SQRT3 * (float(row) + float(column) * 0.5) var center := Vector3(x, 0.0, z) _cells[Vector2i(column, row)] = center var corners := _compute_hex_corners(center, radius) for corner in corners: min_corner_x = min(min_corner_x, corner.x) max_corner_x = max(max_corner_x, corner.x) min_corner_z = min(min_corner_z, corner.z) max_corner_z = max(max_corner_z, corner.z) if min_corner_x == INF: _grid_size = Vector2.ZERO return var size_x: float = max_corner_x - min_corner_x var size_z: float = max_corner_z - min_corner_z var center_offset: Vector3 = Vector3(min_corner_x + size_x * 0.5, 0.0, min_corner_z + size_z * 0.5) for key in _cells.keys(): _cells[key] -= center_offset _hex_center_offset_local = center_offset _grid_size = Vector2(size_x, size_z) func _update_grid_lines() -> void: if not _grid_lines: return if not _show_grid_lines: _grid_lines.visible = false _grid_lines.mesh = null return _grid_lines.visible = true if _grid_type == GridType.HEX: _grid_lines.mesh = _build_hex_grid_mesh() else: _grid_lines.mesh = _build_square_grid_mesh() func _update_grid_visibility() -> void: if not _grid_lines: return _grid_lines.visible = _show_grid_lines func _build_square_grid_mesh() -> ArrayMesh: var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_LINES) var half_width := _grid_size.x * 0.5 var half_depth := _grid_size.y * 0.5 var color := _grid_line_color for column in range(_columns + 1): var x := -half_width + float(column) * _cell_size st.set_color(color) st.add_vertex(Vector3(x, _LINE_HEIGHT_OFFSET, -half_depth)) st.set_color(color) st.add_vertex(Vector3(x, _LINE_HEIGHT_OFFSET, half_depth)) for row in range(_rows + 1): var z := -half_depth + float(row) * _cell_size st.set_color(color) st.add_vertex(Vector3(-half_width, _LINE_HEIGHT_OFFSET, z)) st.set_color(color) st.add_vertex(Vector3(half_width, _LINE_HEIGHT_OFFSET, z)) var mesh := st.commit() return mesh func _build_hex_grid_mesh() -> ArrayMesh: var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_LINES) var color := _grid_line_color var radius: float = max(_cell_size * 0.5, 0.001) for center in _cells.values(): var center_vec: Vector3 = center var corners := _compute_hex_corners(center_vec, radius) for i in range(corners.size()): var a: Vector3 = corners[i] var b: Vector3 = corners[(i + 1) % corners.size()] st.set_color(color) st.add_vertex(a + Vector3(0, _LINE_HEIGHT_OFFSET, 0)) st.set_color(color) st.add_vertex(b + Vector3(0, _LINE_HEIGHT_OFFSET, 0)) var mesh := st.commit() return mesh func _compute_hex_corners(center: Vector3, radius: float) -> Array[Vector3]: var corners: Array[Vector3] = [] for i in range(6): var angle_deg := 60.0 * i var angle := deg_to_rad(angle_deg) var x := center.x + radius * cos(angle) var z := center.z + radius * sin(angle) corners.append(Vector3(x, 0.0, z)) return corners func _update_highlight_mesh() -> void: if not _hover_highlight: return if _grid_type == GridType.HEX: _hover_highlight.mesh = _build_hex_highlight_mesh() else: var plane := PlaneMesh.new() plane.size = Vector2(_cell_size, _cell_size) _hover_highlight.mesh = plane func _build_hex_highlight_mesh() -> ArrayMesh: var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) var radius: float = max(_cell_size * 0.5, 0.001) var corners := _compute_hex_corners(Vector3.ZERO, radius) for i in range(1, corners.size() - 1): st.add_vertex(corners[0]) st.add_vertex(corners[i]) st.add_vertex(corners[i + 1]) var mesh := st.commit() return mesh func _world_to_square_cell(local_position: Vector3, clamp_to_bounds: bool) -> Vector2i: var half_width := _grid_size.x * 0.5 var half_depth := _grid_size.y * 0.5 var normalized_x := (local_position.x + half_width) / _cell_size var normalized_z := (local_position.z + half_depth) / _cell_size var x := int(floor(normalized_x)) var z := int(floor(normalized_z)) if clamp_to_bounds: x = clamp(x, 0, _columns - 1) z = clamp(z, 0, _rows - 1) return Vector2i(x, z) func _world_to_hex_cell(local_position: Vector3, clamp_to_bounds: bool) -> Vector2i: var adjusted: Vector3 = local_position + _hex_center_offset_local var axial: Vector2 = _local_hex_to_axial(adjusted) var cube: Vector3 = _axial_to_cube(axial) var rounded: Vector3 = _cube_round(cube) var q: int = int(rounded.x) var r: int = int(rounded.z) if clamp_to_bounds: q = clamp(q, 0, _columns - 1) r = clamp(r, 0, _rows - 1) elif q < 0 or q >= _columns or r < 0 or r >= _rows: return Vector2i(-1, -1) return Vector2i(q, r) func _local_hex_to_axial(local_position: Vector3) -> Vector2: var radius: float = max(_cell_size * 0.5, 0.0001) var q: float = (2.0 / 3.0 * local_position.x) / radius var r: float = ((-1.0 / 3.0) * local_position.x + (_SQRT3 / 3.0) * local_position.z) / radius return Vector2(q, r) func _axial_to_cube(axial: Vector2) -> Vector3: var x: float = axial.x var z: float = axial.y var y: float = -x - z return Vector3(x, y, z) func _cube_round(cube: Vector3) -> Vector3: var rx: float = round(cube.x) var ry: float = round(cube.y) var rz: float = round(cube.z) var x_diff: float = abs(rx - cube.x) var y_diff: float = abs(ry - cube.y) var z_diff: float = abs(rz - cube.z) if x_diff > y_diff and x_diff > z_diff: rx = -ry - rz elif y_diff > z_diff: ry = -rx - rz else: rz = -rx - ry return Vector3(rx, ry, rz) func _update_camera_view() -> void: if not _camera: return var extent: float = max(_grid_size.x, _grid_size.y) var half_extent: float = extent * 0.5 var distance: float = max(half_extent * 1.1, 6.0) * _zoom_factor var height: float = max(half_extent * 0.8, 6.0) * _zoom_factor var offset: Vector3 = Vector3(distance, height, distance) var target: Vector3 = global_transform.origin var offset_global: Vector3 = global_transform.basis * offset var desired_position: Vector3 = target + offset_global _camera.look_at_from_position(desired_position, target, Vector3.UP) func _compute_required_ground_size() -> Vector2: var base_half := Vector2(_grid_size.x * 0.5, _grid_size.y * 0.5) var half_extents := base_half if is_instance_valid(_camera) and not Engine.is_editor_hint(): var viewport := get_viewport() if viewport: var view_size: Vector2 = viewport.get_visible_rect().size var corners := [ Vector2.ZERO, Vector2(view_size.x, 0), Vector2(0, view_size.y), view_size ] var plane_normal: Vector3 = global_transform.basis.y.normalized() var plane_point: Vector3 = global_transform.origin for corner in corners: var origin := _camera.project_ray_origin(corner) var direction := _camera.project_ray_normal(corner) var denominator := plane_normal.dot(direction) if abs(denominator) < 0.0001: continue var t := plane_normal.dot(plane_point - origin) / denominator if t <= 0.0: continue var world_point := origin + direction * t var local_point := to_local(world_point) half_extents.x = max(half_extents.x, abs(local_point.x)) half_extents.y = max(half_extents.y, abs(local_point.z)) var padding_vec := Vector2.ONE * _ground_padding return (half_extents + padding_vec) * 2.0 func _process(_delta: float) -> void: if Engine.is_editor_hint(): return var mouse_position: Vector2 = get_viewport().get_mouse_position() _update_hovered_cell(mouse_position) func _unhandled_input(event: InputEvent) -> void: if Engine.is_editor_hint(): return if event is InputEventMouseButton and event.pressed: var button_event := event as InputEventMouseButton if button_event.button_index == MOUSE_BUTTON_WHEEL_UP: _adjust_zoom(-_zoom_step_internal) elif button_event.button_index == MOUSE_BUTTON_WHEEL_DOWN: _adjust_zoom(_zoom_step_internal) func _adjust_zoom(delta: float) -> void: var previous := _zoom_factor _zoom_factor = clamp(_zoom_factor + delta, _zoom_min_internal, _zoom_max_internal) if is_equal_approx(previous, _zoom_factor): return _update_camera_view() _update_ground_assets() func _update_hovered_cell(mouse_position: Vector2) -> void: if not _camera or not _hover_highlight: return var ray_origin: Vector3 = _camera.project_ray_origin(mouse_position) var ray_direction: Vector3 = _camera.project_ray_normal(mouse_position) var plane_normal: Vector3 = global_transform.basis.y.normalized() var plane_point: Vector3 = global_transform.origin var denominator: float = plane_normal.dot(ray_direction) if abs(denominator) < 0.0001: _clear_hover() return var t: float = plane_normal.dot(plane_point - ray_origin) / denominator if t <= 0.0: _clear_hover() return var world_point: Vector3 = ray_origin + ray_direction * t var cell_index: Vector2i = world_to_cell(world_point, false) if cell_index.x < 0 or cell_index.y < 0 or not _cells.has(cell_index): _clear_hover() return if cell_index == _hovered_cell: return _hovered_cell = cell_index var local_center: Vector3 = cell_to_world(cell_index) var target_position: Vector3 = to_global(local_center) _hover_highlight.global_position = target_position + plane_normal * 0.02 _hover_highlight.visible = true func _clear_hover() -> void: if _hover_highlight: _hover_highlight.visible = false _hovered_cell = Vector2i(-1, -1)