Files
millimeters-of-aluminum/src/scenes/character/player_controller_3d.gd
2025-12-03 12:45:44 +01:00

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())