# PlayerController3D.gd extends Node class_name PlayerController3D @onready var possessed_pawn: CharacterPawn3D = get_parent() # --- Mouse Sensitivity --- @export var mouse_sensitivity: float = 0.002 # Radians per pixel motion var _mouse_motion_input: Vector2 = Vector2.ZERO # --- Builder State --- var active_preview_piece: ProceduralPiece = null var active_structure_data: StructureData = null var build_mode_enabled: bool = false var is_snap_valid: bool = false # Track if we are currently snapped # Resources const SQUARE_PIECES: Array[StructureData] = [ preload("res://data/structure/definitions/1m_square_flat.tres"), preload("res://data/structure/definitions/1m_square_dome_top.tres"), ] const TRIANGLE_PIECES: Array[StructureData] = [ preload("res://data/structure/definitions/s2_equilateral_tri.tres"), preload("res://data/structure/definitions/s2_geo_tri.tres"), preload("res://data/structure/definitions/s2_geo_v2_a.tres"), preload("res://data/structure/definitions/s2_geo_v2_b.tres"), ] const PROCEDURAL_PIECE_SCENE = preload("res://scenes/ship/builder/pieces/procedural_piece.tscn") var current_piece_index: int = 0 var current_rotation_step: int = 0 # 0 to 3, representing 0, 90, 180, 270 degrees class KeyInput: var pressed: bool = false var held: bool = false var released: bool = false func _init(_p: bool = false, _h: bool = false, _r: bool = false): pressed = _p held = _h released = _r # Helper to convert to Dictionary for RPC func to_dict() -> Dictionary: return {"p": pressed, "h": held, "r": released} # Helper to create from Dictionary static func from_dict(d: Dictionary) -> KeyInput: return KeyInput.new(d.get("p", false), d.get("h", false), d.get("r", false)) func _ready(): # Fallback: assume the pawn's name is the player ID. if get_parent().name.is_valid_int(): set_multiplayer_authority(int(get_parent().name)) func _unhandled_input(event: InputEvent): # Check if THIS client is the owner of this controller if not is_multiplayer_authority() or not is_instance_valid(possessed_pawn): return # Toggle Build Mode if event.is_action_pressed("toggle_build_mode"): # Map 'B' or similar in Project Settings build_mode_enabled = !build_mode_enabled if not build_mode_enabled: _clear_preview() Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) # Ensure mouse captured when leaving else: current_piece_index = 0 _select_piece(SQUARE_PIECES[current_piece_index]) print("Build Mode Enabled") if not build_mode_enabled: if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: _mouse_motion_input += Vector2(event.relative.x, -event.relative.y) return # --- Build Mode Inputs --- # --- Piece Rotation (R key) --- if event is InputEventKey and event.pressed and event.keycode == KEY_R: current_rotation_step = (current_rotation_step + 1) % 4 _update_preview_transform() # Update immediately # --- Piece Cycling (Brackets [ ]) --- if event is InputEventKey and event.pressed: if event.keycode == KEY_1: current_piece_index = (current_piece_index - 1 + SQUARE_PIECES.size()) % SQUARE_PIECES.size() _select_piece(SQUARE_PIECES[current_piece_index]) elif event.keycode == KEY_2: current_piece_index = (current_piece_index + 1) % TRIANGLE_PIECES.size() % TRIANGLE_PIECES.size() _select_piece(TRIANGLE_PIECES[current_piece_index]) if event is InputEventMouseButton: if event.button_index == MOUSE_BUTTON_LEFT and event.pressed: _place_piece() # Allow camera look while holding right click in build mode if event.button_index == MOUSE_BUTTON_RIGHT: if event.pressed: Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) else: Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) # Handle mouse motion input directly here if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: _mouse_motion_input += Vector2(event.relative.x, -event.relative.y) func _physics_process(_delta): # Check if THIS client is the owner if not is_multiplayer_authority() or not is_instance_valid(possessed_pawn): return # STRENGTHENED CHECK: Ensure pawn is valid and inside the tree if not is_instance_valid(possessed_pawn) or not possessed_pawn.is_inside_tree(): return # 1. Handle Mouse Rotation if _mouse_motion_input != Vector2.ZERO: var sensitivity_modified_mouse_input = Vector2(_mouse_motion_input.x, _mouse_motion_input.y) * mouse_sensitivity # Send to Server (ID 1) server_process_rotation_input.rpc_id(1, sensitivity_modified_mouse_input) _mouse_motion_input = Vector2.ZERO # 2. Gather Movement Inputs (Only process movement if NOT in build mode, or perhaps allow moving while building?) # Let's allow movement while building for now. var move_vec = Input.get_vector("move_left_3d", "move_right_3d", "move_backward_3d", "move_forward_3d") var roll_input = Input.get_action_strength("roll_right_3d") - Input.get_action_strength("roll_left_3d") var vertical_input = Input.get_action_strength("move_up_3d") - Input.get_action_strength("move_down_3d") var interact_input = KeyInput.new(Input.is_action_just_pressed("spacebar_3d"), Input.is_action_pressed("spacebar_3d"), Input.is_action_just_released("spacebar_3d")) var l_input = KeyInput.new(Input.is_action_just_pressed("left_click"), Input.is_action_pressed("left_click"), Input.is_action_just_released("left_click")) var r_input = KeyInput.new(Input.is_action_just_pressed("right_click"), Input.is_action_pressed("right_click"), Input.is_action_just_released("right_click")) # Send to Server (ID 1), converting KeyInput objects to Dictionaries server_process_movement_input.rpc_id(1, move_vec, roll_input, vertical_input) server_process_interaction_input.rpc_id(1, interact_input.to_dict()) server_process_clicks.rpc_id(1, l_input.to_dict(), r_input.to_dict()) # 3. Update Builder Preview if build_mode_enabled and active_preview_piece: _update_preview_transform() # --- Builder Functions --- func _select_piece(piece_data: StructureData): _clear_preview() active_structure_data = piece_data print("Selected piece for building:", piece_data.piece_name) if active_structure_data: var piece = PROCEDURAL_PIECE_SCENE.instantiate() piece.structure_data = active_structure_data piece.is_preview = true get_tree().current_scene.add_child(piece) active_preview_piece = piece func _update_preview_transform(): if not is_instance_valid(possessed_pawn): return is_snap_valid = false # Reset snap state var cam = possessed_pawn.camera var space_state = possessed_pawn.get_world_3d().direct_space_state var mouse_pos = get_viewport().get_mouse_position() var from = cam.project_ray_origin(mouse_pos) var dir = cam.project_ray_normal(mouse_pos) # --- NEW: Use SnappingTool to perform the physics sweep --- # This replaces the simple raycast with a thick cylinder cast var hit_result = SnappingTool.find_snap_target(space_state, from, dir, 10.0, 0.2) if not hit_result.is_empty(): var collider = hit_result["collider"] var hit_pos = hit_result["position"] var target_module: Module = null if collider is PieceMount: # If we hit a mount, get its piece and its module, get its module if collider.get_parent() is StructuralPiece: target_module = collider.get_parent().get_parent() if collider.owner is Module: target_module = collider.owner elif collider.get_parent() is Module: target_module = collider.get_parent() if target_module: # Attempt Snap using the hit position var snap_transform = SnappingTool.get_best_snap_transform( active_structure_data, target_module, hit_pos, # Ray hit position ) # If the transform has changed significantly from the hit pos, it means a snap occurred. # (Simple heuristic: check distance from hit to new origin) if snap_transform.origin.distance_to(hit_pos) < SnappingTool.SNAP_DISTANCE: active_preview_piece.global_transform = snap_transform is_snap_valid = true _update_preview_color(Color.GREEN) return # Fallback: Float in front of player var float_pos = from + dir * 3.0 active_preview_piece.global_position = float_pos # Orient to face camera roughly active_preview_piece.look_at(cam.global_position, Vector3.UP) _update_preview_color(Color.CYAN) # Cyan = Floating func _update_preview_color(color: Color): if not is_instance_valid(active_preview_piece): return var mesh_inst = active_preview_piece.find_child("MeshInstance3D") if mesh_inst and mesh_inst.material_override: var mat = mesh_inst.material_override as StandardMaterial3D mat.albedo_color = Color(color.r, color.g, color.b, 0.4) mat.emission = color func _place_piece(): if not active_preview_piece or not active_structure_data: return # Tell Server to spawn the real piece at this transform server_request_place_piece.rpc_id(1, active_structure_data.resource_path, active_preview_piece.global_transform) func _clear_preview(): if is_instance_valid(active_preview_piece): active_preview_piece.queue_free() active_preview_piece = null # --- RPCs: Allow "any_peer" so clients can call this on the Server --- @rpc("any_peer", "call_local") func server_request_place_piece(resource_path: String, transform: Transform3D): # Server validation logic here (distance check, cost check) # Security: Check if sender is allowed to build var res = load(resource_path) as StructureData if not res: return # Find nearby module to attach to var query_pos = transform.origin var module: Module = _find_module_near_server(query_pos) if not module: # Logic to create a new module could go here print("No module nearby to attach piece.") var new_module = Module.new() new_module.name = "Module_%s" % get_process_delta_time() # Unique name possessed_pawn.get_parent().add_child(new_module) new_module.global_position = query_pos new_module.physics_mode = OrbitalBody3D.PhysicsMode.COMPOSITE module = new_module print("Created new module %s for piece placement." % new_module.name) if module: var piece: ProceduralPiece = PROCEDURAL_PIECE_SCENE.instantiate() piece.structure_data = res module.add_child(piece) piece.global_transform = transform piece.owner = module # Ensure persistence # Trigger weld logic on the new piece piece.try_weld() module.recalculate_physical_properties() print("Placed piece %s on module %s" % [piece.name, module]) # Helper to find modules on server side (uses global overlap check) func _find_module_near_server(pos: Vector3) -> Module: # Create a sphere query parameters object var space_state = possessed_pawn.get_world_3d().direct_space_state var params = PhysicsShapeQueryParameters3D.new() var shape = SphereShape3D.new() shape.radius = 5.0 # Search radius params.shape = shape params.transform = Transform3D(Basis(), pos) params.collide_with_bodies = true params.collision_mask = 0xFFFFFFFF # Check all layers, or specific module layer var results = space_state.intersect_shape(params) for result in results: var collider = result["collider"] # Case 1: We hit the Module directly (RigidBody3D) if collider is Module: return collider # Case 2: We hit a StructuralPiece attached to a Module if collider is StructuralPiece: # StructuralPiece should have a way to get its root module if collider.get_parent() is Module: return collider.get_parent() # Or if you use the helper: # return collider.get_root_module() return null @rpc("any_peer", "call_local") func server_process_movement_input(move: Vector2, roll: float, vertical: float): if is_instance_valid(possessed_pawn): # Debug: Uncomment to verify flow # if move.length() > 0: print("Server Pawn %s Move: %s" % [owner_id, move]) possessed_pawn.set_movement_input(move, roll, vertical) @rpc("any_peer", "call_local") func server_process_interaction_input(interact_data: Dictionary): if is_instance_valid(possessed_pawn): if interact_data.has_all(["p", "h", "r"]): possessed_pawn.set_interaction_input(KeyInput.from_dict(interact_data)) @rpc("any_peer", "call_local") func server_process_rotation_input(input: Vector2): if is_instance_valid(possessed_pawn): possessed_pawn.set_rotation_input(input) @rpc("any_peer", "call_local") func server_process_clicks(l_data: Dictionary, r_data: Dictionary): if is_instance_valid(possessed_pawn): var l_action = KeyInput.from_dict(l_data) if l_data.has("p") else KeyInput.new() var r_action = KeyInput.from_dict(r_data) if r_data.has("p") else KeyInput.new() possessed_pawn.set_click_input(l_action, r_action) # Optional: Release mouse when losing focus func _notification(what): match what: NOTIFICATION_WM_WINDOW_FOCUS_OUT: Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) NOTIFICATION_WM_WINDOW_FOCUS_IN: Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) NOTIFICATION_EXIT_TREE: print("PlayerController exited tree") NOTIFICATION_ENTER_TREE: print("PlayerController %s entered tree" % multiplayer.get_unique_id())