Files
CosmicClash/Game/scripts/goal.gd
T
2026-08-08 15:03:46 +01:00

269 lines
10 KiB
GDScript

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