328 lines
13 KiB
GDScript
328 lines
13 KiB
GDScript
# 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())
|