diff --git a/Game/scripts/ball.gd b/Game/scripts/ball.gd index c3e78800..306845d3 100644 --- a/Game/scripts/ball.gd +++ b/Game/scripts/ball.gd @@ -17,10 +17,54 @@ extends RigidBody3D const MAX_SPEED := 32.0 var _boundary: ArenaBoundary +var _trail: GPUParticles3D 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 _trail: + var speed_ratio := clampf(linear_velocity.length() / 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: diff --git a/Game/scripts/goal.gd b/Game/scripts/goal.gd index c966270e..71363942 100644 --- a/Game/scripts/goal.gd +++ b/Game/scripts/goal.gd @@ -26,6 +26,8 @@ const RIM_DEPTH := 0.05 const NET_SHADER_PATH := "res://shaders/goal_net.gdshader" +var _goal_burst: GPUParticles3D + func _ready(): # Group lets AI controllers and game modes discover goals @@ -34,10 +36,13 @@ func _ready(): # headless arenas that never render it. The sensor is unaffected. if DisplayServer.get_name() != "headless": _build_visuals() + _build_goal_burst() func _on_body_entered(body): if body.is_in_group("ball"): + if _goal_burst: + _goal_burst.restart() goal_scored.emit(team) @@ -103,6 +108,43 @@ func _build_visuals() -> void: add_child(visuals) +func _build_goal_burst() -> void: + var tint: Color = TeamColors.TEAM_COLORS[team % TeamColors.TEAM_COLORS.size()] + 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(tint.r, tint.g, tint.b, 0.9) + mat.emission_enabled = true + mat.emission = tint + mat.emission_energy_multiplier = 3.0 + var quad := QuadMesh.new() + quad.size = Vector2(0.38, 0.8) + quad.material = mat + var process := ParticleProcessMaterial.new() + process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + process.emission_box_extents = Vector3(1.7, 0.7, 0.15) + process.direction = Vector3(0, 0, -1) + process.spread = 48.0 + process.initial_velocity_min = 8.0 + process.initial_velocity_max = 18.0 + process.gravity = Vector3(0, -2.0, 0) + process.scale_min = 0.35 + process.scale_max = 1.2 + _goal_burst = GPUParticles3D.new() + _goal_burst.name = "GoalBurst" + _goal_burst.position = Vector3(0, 0, -0.15) + _goal_burst.amount = 140 + _goal_burst.lifetime = 1.1 + _goal_burst.one_shot = true + _goal_burst.explosiveness = 0.92 + _goal_burst.process_material = process + _goal_burst.draw_pass_1 = quad + _goal_burst.visibility_aabb = AABB(Vector3(-12, -8, -20), Vector3(24, 16, 24)) + _goal_burst.emitting = false + add_child(_goal_burst) + + # Four bars around the mouth. The uprights run the full outer height so each # corner is covered exactly once. func _add_frame_ring( diff --git a/Game/scripts/ship.gd b/Game/scripts/ship.gd index 049e314a..315ec120 100644 --- a/Game/scripts/ship.gd +++ b/Game/scripts/ship.gd @@ -97,6 +97,7 @@ signal altitude_changed(altitude: float) signal thrust_changed(thrust_percent: float) signal angular_velocity_changed(angular_speed: float) signal heading_changed(heading_degrees: float) +signal ball_contact(intensity: float, world_position: Vector3) # Performance optimization - track last emitted values to avoid unnecessary signals var _last_speed: float = -1.0 @@ -115,6 +116,12 @@ const ANGULAR_THRESHOLD = 0.01 # rad/s const ATTITUDE_THRESHOLD = 1.0 # degrees const THRUST_THRESHOLD = 1.0 # percent +var _engine_cores: Array[MeshInstance3D] = [] +var _engine_plumes: Array[GPUParticles3D] = [] +var _turbo_flames: Array[GPUParticles3D] = [] +var _engine_lights: Array[OmniLight3D] = [] +var _impact_sparks: GPUParticles3D + func _ready(): # Add ship to group for instrument discovery @@ -148,6 +155,8 @@ func _ready(): set_physics_process(false) else: _build_merged_hull() + _build_movement_vfx() + body_entered.connect(_on_body_entered) func _apply_team_color() -> void: @@ -197,10 +206,139 @@ func set_controller(new_controller: ShipController) -> void: func _physics_process(_delta): + _update_movement_vfx() if _has_telemetry_listeners(): _emit_telemetry_data() +func _build_movement_vfx() -> void: + for x in [-0.42, 0.42]: + var engine_pos := Vector3(x, -0.05, 1.42) + + var core_mat := _vfx_material(Color(0.35, 0.85, 1.0, 1.0), 1.0) + var core_mesh := SphereMesh.new() + core_mesh.radius = 0.11 + core_mesh.height = 0.22 + core_mesh.material = core_mat + var core := MeshInstance3D.new() + core.name = "EngineCoreL" if x < 0.0 else "EngineCoreR" + core.position = engine_pos + core.mesh = core_mesh + core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(core) + _engine_cores.append(core) + + var plume := _make_particles( + "EnginePlumeL" if x < 0.0 else "EnginePlumeR", + Color(0.3, 0.82, 1.0, 0.72), Vector2(0.16, 0.42), 36, 0.24, + Vector3(0, 0, 1), 12.0, 2.0, 5.0 + ) + plume.position = engine_pos + Vector3(0, 0, 0.08) + add_child(plume) + _engine_plumes.append(plume) + + var turbo := _make_particles( + "TurboFlameL" if x < 0.0 else "TurboFlameR", + Color(0.78, 0.34, 1.0, 0.9), Vector2(0.22, 0.72), 48, 0.18, + Vector3(0, 0, 1), 7.0, 6.0, 10.0 + ) + turbo.position = engine_pos + Vector3(0, 0, 0.12) + add_child(turbo) + _turbo_flames.append(turbo) + + var light := OmniLight3D.new() + light.name = "EngineLightL" if x < 0.0 else "EngineLightR" + light.position = engine_pos + light.light_color = Color(0.3, 0.75, 1.0) + light.omni_range = 3.5 + light.omni_attenuation = 2.0 + light.shadow_enabled = false + add_child(light) + _engine_lights.append(light) + + _impact_sparks = _make_particles( + "ImpactSparks", Color(1.0, 0.72, 0.22, 1.0), Vector2(0.09, 0.3), + 28, 0.32, Vector3.UP, 180.0, 5.0, 12.0 + ) + _impact_sparks.one_shot = true + _impact_sparks.explosiveness = 1.0 + _impact_sparks.local_coords = false + _impact_sparks.emitting = false + add_child(_impact_sparks) + + +func _vfx_material(color: Color, energy: float) -> StandardMaterial3D: + 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 + mat.emission_enabled = true + mat.emission = Color(color.r, color.g, color.b) + mat.emission_energy_multiplier = energy + return mat + + +func _make_particles( + particle_name: String, color: Color, size: Vector2, amount: int, + lifetime: float, direction: Vector3, spread: float, + velocity_min: float, velocity_max: float +) -> GPUParticles3D: + var quad := QuadMesh.new() + quad.size = size + quad.material = _vfx_material(color, 2.2) + var process := ParticleProcessMaterial.new() + process.direction = direction + process.spread = spread + process.initial_velocity_min = velocity_min + process.initial_velocity_max = velocity_max + process.gravity = Vector3.ZERO + process.scale_min = 0.35 + process.scale_max = 1.0 + var particles := GPUParticles3D.new() + particles.name = particle_name + particles.amount = amount + particles.lifetime = lifetime + particles.local_coords = true + particles.process_material = process + particles.draw_pass_1 = quad + particles.visibility_aabb = AABB(Vector3(-3, -3, -1), Vector3(6, 6, 12)) + particles.emitting = false + return particles + + +func _update_movement_vfx() -> void: + if _engine_plumes.is_empty(): + return + # These are the two rear main engines, so lateral/vertical maneuvering jets + # must not make them flare. Reverse thrust also comes from separate attitude + # jets conceptually; only positive Z drives this rear-facing plume. + var thrust := clampf(maxf(_current_action.thrust.z, 0.0), 0.0, 1.0) + var turbo := _current_action.turbo and thrust > 0.05 + for i in _engine_plumes.size(): + _engine_plumes[i].emitting = thrust > 0.02 + _engine_plumes[i].amount_ratio = 0.25 + thrust * 0.75 + _engine_plumes[i].speed_scale = 0.65 + thrust * 0.75 + _turbo_flames[i].emitting = turbo + _engine_lights[i].light_energy = 0.35 + thrust * 2.1 + (2.3 if turbo else 0.0) + var core_mat := _engine_cores[i].mesh.material as StandardMaterial3D + core_mat.emission_energy_multiplier = 0.65 + thrust * 2.0 + (2.0 if turbo else 0.0) + _engine_cores[i].scale = Vector3.ONE * (0.8 + thrust * 0.3 + (0.25 if turbo else 0.0)) + + +func _on_body_entered(body: Node) -> void: + if not body is Ball: + return + var relative_speed := (linear_velocity - (body as Ball).linear_velocity).length() + var intensity := clampf(inverse_lerp(3.0, 24.0, relative_speed), 0.12, 1.0) + var contact_pos := (global_position + (body as Ball).global_position) * 0.5 + if _impact_sparks: + _impact_sparks.position = to_local(contact_pos) + _impact_sparks.amount_ratio = lerpf(0.25, 1.0, intensity) + _impact_sparks.restart() + ball_contact.emit(intensity, contact_pos) + + # Covers a second/AI ship with no HUD watching it - headless mode is already # handled by disabling _physics_process entirely in _ready. func _has_telemetry_listeners() -> bool: diff --git a/TODO.md b/TODO.md index 16384aeb..556e3bca 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ The largest gap between this and a AAA-feeling product is presentation, not code - [ ] **Audio — there is none.** Zero sound files, zero `AudioStreamPlayer` nodes, no bus layout. Needs: engine hum pitched to throttle, turbo whoosh, ball impacts scaled by collision impulse, wall scrapes, goal explosion, crowd bed, UI clicks, countdown beeps, music. Can be driven off `Ship`'s existing telemetry signals. - [x] SSAO/SSIL in the arena Environments — cheapest single perceived-quality win available; grounds the ships against the deck and gives the fillets and goal recesses real depth. -- [ ] VFX on anything that moves: `EngineGlowL/R` are static meshes that don't respond to throttle. No thruster plume, turbo flame, ball trail, impact sparks or goal burst. `arena_02` is the only scene with any particles at all. +- [x] VFX on anything that moves: throttle-reactive engine cores and plumes, distinct turbo flames, a speed-scaled ball trail, contact sparks and team-tinted goal bursts. - [ ] Impact feedback — screen shake, hit-stop, flash, controller rumble on ball contact. - [ ] Camera feel in `ship_camera.gd` — fixed distance/height/FOV today. Speed-based FOV widening, turbo kick, impact shake. - [ ] Goal celebration sequence: today it's a `print()` and a label scale-pop. Wants an explosion, team-tinted screen flash, camera cut, slow-mo, title card. `HUDController`'s `ResultOverlay` animation is a reasonable template.