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) # 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 # Physical back wall for the goal pocket. The arena end wall has a real hole # at the scoring aperture; without this, the visual-only net lets ships and # the ball leave the enclosure after crossing the sensor. A small overlap # into the surrounding end-wall panels closes numerical seams at the rim. const BACKSTOP_THICKNESS := 0.4 const BACKSTOP_EDGE_OVERLAP := 0.25 const ARENA_COLLISION_LAYER := 1 << 2 const ARENA_COLLISION_MASK := (1 << 0) | (1 << 1) # 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" var _goal_burst: GPUParticles3D func _ready(): # Group lets AI controllers and game modes discover goals add_to_group("goal") _build_backstop() # 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() _build_goal_burst() func _on_body_entered(body): if body.is_in_group("ball"): if _goal_burst: _goal_burst.restart() goal_scored.emit(team) func _build_backstop() -> void: var mouth := ($CollisionShape3D.shape as BoxShape3D).size var body := StaticBody3D.new() body.name = "PocketBackstop" body.collision_layer = ARENA_COLLISION_LAYER body.collision_mask = ARENA_COLLISION_MASK var collision := CollisionShape3D.new() collision.name = "CollisionShape3D" var box := BoxShape3D.new() box.size = Vector3( mouth.x + BACKSTOP_EDGE_OVERLAP * 2.0, mouth.y + BACKSTOP_EDGE_OVERLAP * 2.0, BACKSTOP_THICKNESS ) collision.shape = box collision.position = Vector3(0.0, 0.0, POCKET_DEPTH) body.add_child(collision) add_child(body) # --- 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 = TeamColors.TEAM_COLORS[team % TeamColors.TEAM_COLORS.size()] # One ArrayMesh, four surfaces (pocket, net, bezel, rim). They stay separate # surfaces rather than sharing materials because they aren't visually # interchangeable: the rim is a team-tinted emitter at a tuned energy, the # net carries its own discard-based shader, and the pocket/bezel differ in # albedo/metallic/roughness. Merging those would be a visual regression, # not a free draw-call win — this still takes the goal from 10 nodes/4 # materials down to 1 node/4 materials. var mesh := ArrayMesh.new() # Pocket shell. Built with inverted winding so it reads correctly from the # inside (a void carved into the hull) without needing a material-level # CULL_FRONT override. var pocket_st := SurfaceTool.new() pocket_st.begin(Mesh.PRIMITIVE_TRIANGLES) _add_box_to_surface(pocket_st, Vector3(mouth.x + POCKET_CLEARANCE * 2.0, mouth.y + POCKET_CLEARANCE * 2.0, POCKET_DEPTH), Vector3(0, 0, POCKET_DEPTH / 2.0), true) pocket_st.commit(mesh) mesh.surface_set_material(0, _surface(Color(0.016, 0.018, 0.026), 0.2, 0.9)) # The net is the same trick, but the "seen from inside" flip is baked into # goal_net.gdshader's own `render_mode cull_front` (and its FRONT_FACING # normal flip), so this geometry must keep standard, non-inverted winding # to match what that shader expects. var net_st := SurfaceTool.new() net_st.begin(Mesh.PRIMITIVE_TRIANGLES) _add_box_to_surface(net_st, Vector3(mouth.x, mouth.y, POCKET_DEPTH * 0.88), Vector3(0, 0, POCKET_DEPTH / 2.0)) net_st.commit(mesh) mesh.surface_set_material(1, _net_material(tint)) # Bezel lines the opening (inset into the wall); the rim sits flush with # the wall face and carries the team colour. var bezel_st := SurfaceTool.new() bezel_st.begin(Mesh.PRIMITIVE_TRIANGLES) _add_frame_ring(bezel_st, half, BEZEL_THICKNESS, BEZEL_DEPTH, BEZEL_DEPTH / 2.0) bezel_st.commit(mesh) mesh.surface_set_material(2, _surface(Color(0.05, 0.055, 0.07), 0.75, 0.32)) var rim_st := SurfaceTool.new() rim_st.begin(Mesh.PRIMITIVE_TRIANGLES) _add_frame_ring(rim_st, half, RIM_THICKNESS, RIM_DEPTH, -RIM_DEPTH / 2.0) rim_st.commit(mesh) mesh.surface_set_material(3, _emissive(tint)) var visuals := MeshInstance3D.new() visuals.name = "Visuals" visuals.mesh = mesh visuals.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF 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( st: SurfaceTool, half: Vector2, thickness: float, depth: float, z: float ) -> void: for sx in [-1.0, 1.0]: _add_box_to_surface(st, Vector3(thickness, (half.y + thickness) * 2.0, depth), Vector3(sx * (half.x + thickness / 2.0), 0.0, z)) for sy in [-1.0, 1.0]: _add_box_to_surface(st, Vector3(half.x * 2.0, thickness, depth), Vector3(0.0, sy * (half.y + thickness / 2.0), z)) # Emits a box's 6 faces as quads into st, centered at pos. With invert, face # winding/normals are flipped — used for the pocket so it renders correctly # as seen from inside without a material-level CULL_FRONT override. func _add_box_to_surface( st: SurfaceTool, size: Vector3, pos: Vector3, invert: bool = false ) -> void: var h := size / 2.0 var n := -1.0 if invert else 1.0 # The 8 corners of the box, named by the sign of each axis. var nnn := pos + Vector3(-h.x, -h.y, -h.z) var nnp := pos + Vector3(-h.x, -h.y, h.z) var npn := pos + Vector3(-h.x, h.y, -h.z) var npp := pos + Vector3(-h.x, h.y, h.z) var pnn := pos + Vector3(h.x, -h.y, -h.z) var pnp := pos + Vector3(h.x, -h.y, h.z) var ppn := pos + Vector3(h.x, h.y, -h.z) var ppp := pos + Vector3(h.x, h.y, h.z) _add_quad(st, pnn, Vector3(n, 0, 0), ppn, Vector3(n, 0, 0), ppp, Vector3(n, 0, 0), pnp, Vector3(n, 0, 0)) _add_quad(st, nnn, Vector3(-n, 0, 0), nnp, Vector3(-n, 0, 0), npp, Vector3(-n, 0, 0), npn, Vector3(-n, 0, 0)) _add_quad(st, npn, Vector3(0, n, 0), npp, Vector3(0, n, 0), ppp, Vector3(0, n, 0), ppn, Vector3(0, n, 0)) _add_quad(st, nnn, Vector3(0, -n, 0), pnn, Vector3(0, -n, 0), pnp, Vector3(0, -n, 0), nnp, Vector3(0, -n, 0)) _add_quad(st, nnp, Vector3(0, 0, n), pnp, Vector3(0, 0, n), ppp, Vector3(0, 0, n), npp, Vector3(0, 0, n)) _add_quad(st, nnn, Vector3(0, 0, -n), npn, Vector3(0, 0, -n), ppn, Vector3(0, 0, -n), pnn, Vector3(0, 0, -n)) # Quad a-b-c-d with per-vertex normals, wound so the front faces the normals # (Godot front faces wind clockwise when seen from the normal side). Copied # from arena_boundary.gd's _add_quad/_add_tri rather than shared, since that # file's geometry is collision-adjacent and not worth coupling to. func _add_quad( st: SurfaceTool, a: Vector3, na: Vector3, b: Vector3, nb: Vector3, c: Vector3, nc: Vector3, d: Vector3, nd: Vector3 ) -> void: if (b - a).cross(c - a).dot(na + nb + nc + nd) < 0.0: _add_tri(st, a, na, b, nb, c, nc) _add_tri(st, a, na, c, nc, d, nd) else: _add_tri(st, a, na, d, nd, c, nc) _add_tri(st, a, na, c, nc, b, nb) func _add_tri( st: SurfaceTool, a: Vector3, na: Vector3, b: Vector3, nb: Vector3, c: Vector3, nc: Vector3 ) -> void: st.set_normal(na) st.add_vertex(a) st.set_normal(nb) st.add_vertex(b) st.set_normal(nc) st.add_vertex(c) 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