class_name Goal extends Area3D # A goal is a dumb sensor: it detects the ball crossing its plane and emits # goal_scored. The game mode owns all consequences (score, resets). Two of # these live in each arena, one per team. # The team that concedes when the ball enters this goal. @export var team: int = 0 signal goal_scored(team: int) # Frame tints, indexed by team. const TEAM_COLORS := [Color(0.2, 0.55, 1.0), Color(1.0, 0.4, 0.28)] # The pocket is sunk into the end wall, so it can be at most as deep as that # wall is thick or it pokes out the back of the arena. ArenaBoundary cuts the # matching aperture in the hull (see its GOAL_APERTURE_* constants). const POCKET_DEPTH := ArenaBoundary.SURFACE_THICKNESS # Clearance between the mouth and the pocket shell, so the pocket's side walls # stay hidden behind the hull rather than showing at the aperture edge. const POCKET_CLEARANCE := 0.2 # Dark machined lining round the opening, then a thin emissive rim flush with # the wall face. const BEZEL_THICKNESS := 0.2 const BEZEL_DEPTH := 0.16 const RIM_THICKNESS := 0.05 const RIM_DEPTH := 0.05 const NET_SHADER_PATH := "res://shaders/goal_net.gdshader" func _ready(): # Group lets AI controllers and game modes discover goals add_to_group("goal") # Everything below _on_body_entered is decoration, and training spawns # headless arenas that never render it. The sensor is unaffected. if DisplayServer.get_name() != "headless": _build_visuals() func _on_body_entered(body): if body.is_in_group("ball"): goal_scored.emit(team) # --- visuals ----------------------------------------------------------------- # Cosmetic only. The mouth is measured off the sensor's own collision shape, so # the frame can never drift from the volume that actually scores. func _build_visuals() -> void: var mouth := ($CollisionShape3D.shape as BoxShape3D).size var half := Vector2(mouth.x, mouth.y) / 2.0 var tint: Color = TEAM_COLORS[team % TEAM_COLORS.size()] # Pocket shell, seen from the inside (hence CULL_FRONT) so it reads as a # void carved into the hull rather than a box stuck onto it. var pocket := _surface(Color(0.016, 0.018, 0.026), 0.2, 0.9) pocket.cull_mode = BaseMaterial3D.CULL_FRONT _add_box( Vector3(mouth.x + POCKET_CLEARANCE * 2.0, mouth.y + POCKET_CLEARANCE * 2.0, POCKET_DEPTH), Vector3(0, 0, POCKET_DEPTH / 2.0), pocket) # The net is the same trick: a box viewed from inside gives a five-sided # pocket of netting from one mesh, instead of a flat panel across the back. _add_box(Vector3(mouth.x, mouth.y, POCKET_DEPTH * 0.88), Vector3(0, 0, POCKET_DEPTH / 2.0), _net_material(tint)) # Bezel lines the opening (inset into the wall); the rim sits flush with # the wall face and carries the team colour. _add_frame_ring(half, BEZEL_THICKNESS, BEZEL_DEPTH, BEZEL_DEPTH / 2.0, _surface(Color(0.05, 0.055, 0.07), 0.75, 0.32)) _add_frame_ring(half, RIM_THICKNESS, RIM_DEPTH, -RIM_DEPTH / 2.0, _emissive(tint)) # Four bars around the mouth. The uprights run the full outer height so each # corner is covered exactly once. func _add_frame_ring( half: Vector2, thickness: float, depth: float, z: float, material: Material ) -> void: for sx in [-1.0, 1.0]: _add_box(Vector3(thickness, (half.y + thickness) * 2.0, depth), Vector3(sx * (half.x + thickness / 2.0), 0.0, z), material) for sy in [-1.0, 1.0]: _add_box(Vector3(half.x * 2.0, thickness, depth), Vector3(0.0, sy * (half.y + thickness / 2.0), z), material) func _add_box(size: Vector3, pos: Vector3, material: Material) -> void: var mesh := BoxMesh.new() mesh.size = size var instance := MeshInstance3D.new() instance.mesh = mesh instance.position = pos instance.material_override = material instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(instance) func _surface(albedo: Color, metallic: float, roughness: float) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_color = albedo mat.metallic = metallic mat.roughness = roughness return mat func _emissive(tint: Color) -> StandardMaterial3D: var mat := _surface(tint.darkened(0.7), 0.4, 0.3) mat.emission_enabled = true mat.emission = tint # Each arena runs glow at hdr_threshold ~1.0, so keep this near it or the # frame blooms into a smear. mat.emission_energy_multiplier = 1.2 return mat func _net_material(tint: Color) -> ShaderMaterial: var mat := ShaderMaterial.new() mat.shader = load(NET_SHADER_PATH) mat.set_shader_parameter("net_color", tint.lightened(0.45)) return mat