mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 17:43:42 +00:00
66b6215fdd
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.
124 lines
4.4 KiB
GDScript
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
|