mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
269 lines
10 KiB
GDScript
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
|