Files
CosmicClash/Game/scripts/goal.gd
T
Josh Creek 66b6215fdd feat(goal): recess goals into the hull behind a netted pocket
The goal was a single flat translucent slab. Because the chase camera sits
behind the goal line at kickoff it rendered as a large blue rectangle across
the lower screen, and the hull beside the mouth was translucent field panel,
so the pocket behind it showed straight through the wall and the net appeared
a second time alongside the frame.

The boundary now carries an opaque bulkhead around each mouth, which occludes
properly. On top of that the goal becomes a pocket sunk into the wall: a dark
machined bezel lining the opening, a thin team-coloured emissive rim flush
with the wall face, and netting from a single box viewed inside-out, giving a
five-sided pocket instead of a flat panel across the back.

The net is cut with discard rather than alpha blending so it still writes
depth and sorts against the frame and the containment field like solid
geometry; a blended net would join the transparent queue and sort per object
against the boundary shell, which is the artefact this whole pass removed.

goal.tscn drops from 18 node/sub-resource blocks to 3 — it is now just the
Area3D, its CollisionShape3D and the script. All geometry is built in code
from two loops, and the mouth is measured off the collision shape rather than
restated, so the frame cannot drift from the volume that actually scores.

The sensor, its collision shape and the goal_scored signal are unchanged, and
visuals are skipped under --headless.
2026-08-04 13:36:07 +01:00

124 lines
4.4 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)
# 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