class_name Ball extends RigidBody3D # Physics ball. Floor gravity is untouched (gravity_scale in ball.tscn); this # only adds the wall/ceiling "surface pull" (see ArenaBoundary.get_surface_pull) # so the ball can cling near a wall for dribbling or hang against the ceiling # for ceiling shots, plus a safety speed clamp (the ball previously had none). @export_group("Surface Pull") @export var wall_pull_strength = 4.0 # Weaker than the ship's — assist, not adherence @export var wall_pull_range = 2.0 @export var ceiling_pull_strength = 5.5 # Stays below the ball's effective gravity (0.8 * 9.8) @export var ceiling_pull_range = 2.0 # Kept close to ship_observations.gd's BALL_SPEED_SCALE (30.0) so this feature # doesn't push ball velocity further out of the range trained policies expect. const MAX_SPEED := 32.0 var _boundary: ArenaBoundary var _trail: GPUParticles3D @onready var visual: Node3D = $Visual var _pending_teleport: Transform3D var _has_pending_teleport := false var _pending_teleport_linear_velocity := Vector3.ZERO var _pending_teleport_angular_velocity := Vector3.ZERO var _pending_teleport_has_velocity := false # Queues an authoritative teleport, applied at the top of the next # _integrate_forces — the only Jolt-safe place to write state.transform # directly (see GameMode._reset_body / task 0.15) — instead of racing the # physics step via set_deferred("global_transform", ...). func queue_teleport(to: Transform3D) -> void: _pending_teleport = to _has_pending_teleport = true _pending_teleport_has_velocity = false # Kept parallel to Ship's network correction hook. A locally predicted ball # must resume from the authoritative velocity after a correction; gameplay # resets still deliberately use queue_teleport() and zero both velocities. # The queued-but-not-yet-applied teleport target, or null when none is # pending. queue_teleport() defers the actual write to the next # _integrate_forces (task 0.15), so global_transform still reads the OLD pose # in between — anything that needs to broadcast where a body is ABOUT to be # (networked_match.gd's kickoff) must read this instead, or it ships the # pre-reset position and corrects it a tick later. func get_pending_teleport(): return _pending_teleport if _has_pending_teleport else null func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void: _pending_teleport = to _pending_teleport_linear_velocity = new_linear_velocity _pending_teleport_angular_velocity = new_angular_velocity _pending_teleport_has_velocity = true _has_pending_teleport = true # -1 = use the real linear_velocity (default; see _physics_process below). # A frozen remote ball (Phase 4) holds zero velocity — Godot/Jolt zeroes and # ignores velocity writes on frozen bodies — so the trail needs a # presentation-only speed fed in from outside instead of reading physics # state that will never reflect the ball's true remote motion. var _visual_speed_override: float = -1.0 # Prediction correction hook: exactly like Ship's visual offset, but kept # here so a locally predicted ball can move its collider to authority while # the mesh catches up over a short presentation-only decay. var net_visual_offset := Vector3.ZERO const NET_VISUAL_OFFSET_DECAY := 0.88 const MAX_NET_VISUAL_OFFSET := 0.4 func set_visual_speed(speed: float) -> void: _visual_speed_override = speed func _ready() -> void: _boundary = get_tree().get_first_node_in_group("arena_boundary") if DisplayServer.get_name() == "headless": # _integrate_forces remains active; only the render-side trail updater is # disabled across the many parallel RL environments. set_physics_process(false) else: _build_trail() func _physics_process(_delta: float) -> void: if net_visual_offset != Vector3.ZERO: net_visual_offset = net_visual_offset.limit_length(MAX_NET_VISUAL_OFFSET) net_visual_offset *= pow(NET_VISUAL_OFFSET_DECAY, _delta * 60.0) if net_visual_offset.length_squared() < 0.0001: net_visual_offset = Vector3.ZERO visual.position = net_visual_offset if _trail: var speed := _visual_speed_override if _visual_speed_override >= 0.0 else linear_velocity.length() var speed_ratio := clampf(speed / MAX_SPEED, 0.0, 1.0) _trail.emitting = speed_ratio > 0.12 _trail.amount_ratio = smoothstep(0.12, 1.0, speed_ratio) func _build_trail() -> void: var mat := StandardMaterial3D.new() mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED mat.albedo_color = Color(0.55, 0.85, 1.0, 0.42) mat.emission_enabled = true mat.emission = Color(0.35, 0.72, 1.0) mat.emission_energy_multiplier = 1.8 var quad := QuadMesh.new() quad.size = Vector2(0.22, 0.22) quad.material = mat var process := ParticleProcessMaterial.new() process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE process.emission_sphere_radius = 0.35 process.gravity = Vector3.ZERO process.scale_min = 0.35 process.scale_max = 1.0 _trail = GPUParticles3D.new() _trail.name = "BallTrail" _trail.amount = 48 _trail.lifetime = 0.48 _trail.local_coords = false _trail.process_material = process _trail.draw_pass_1 = quad _trail.visibility_aabb = AABB(Vector3(-18, -18, -18), Vector3(36, 36, 36)) _trail.emitting = false add_child(_trail) func _integrate_forces(state: PhysicsDirectBodyState3D) -> void: if _has_pending_teleport: _has_pending_teleport = false state.transform = _pending_teleport state.linear_velocity = _pending_teleport_linear_velocity if _pending_teleport_has_velocity else Vector3.ZERO state.angular_velocity = _pending_teleport_angular_velocity if _pending_teleport_has_velocity else Vector3.ZERO _pending_teleport_has_velocity = false reset_physics_interpolation() if _boundary: var pull := _boundary.get_surface_pull( global_position, wall_pull_strength, wall_pull_range, ceiling_pull_strength, ceiling_pull_range ) state.apply_central_force(pull * mass) if state.linear_velocity.length() > MAX_SPEED: state.linear_velocity = state.linear_velocity.normalized() * MAX_SPEED