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.
This commit is contained in:
Josh Creek
2026-08-04 13:36:07 +01:00
parent 02df09e40d
commit 66b6215fdd
4 changed files with 159 additions and 12 deletions
+1 -12
View File
@@ -1,24 +1,13 @@
[gd_scene load_steps=5 format=3 uid="uid://cofdcxo5170rs"]
[gd_scene load_steps=3 format=3 uid="uid://cofdcxo5170rs"]
[ext_resource type="Script" path="res://scripts/goal.gd" id="1_v8ikr"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ptddx"]
transparency = 1
albedo_color = Color(0, 0.368627, 1, 0.537255)
[sub_resource type="BoxMesh" id="BoxMesh_4ngif"]
material = SubResource("StandardMaterial3D_ptddx")
size = Vector3(3.5, 1.5, 0.1)
[sub_resource type="BoxShape3D" id="BoxShape3D_nmd5r"]
size = Vector3(3.5, 1.5, 0.1)
[node name="Goal" type="Area3D"]
script = ExtResource("1_v8ikr")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
mesh = SubResource("BoxMesh_4ngif")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("BoxShape3D_nmd5r")
+102
View File
@@ -10,12 +10,114 @@ extends Area3D
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
+55
View File
@@ -0,0 +1,55 @@
// Netting for the goal pocket.
//
// Applied to a box viewed from the inside (cull_front), so one mesh gives a
// five-sided pocket of netting rather than a flat panel across the back. The
// strand pattern is driven from model-space position rather than UV so spacing
// stays physical and continuous across all five faces — a box's UV layout
// would stretch it differently on each.
//
// Cut with discard rather than alpha blending on purpose: an alpha-scissor net
// still writes depth, so it sorts against the pocket, the frame and the
// arena's containment field like ordinary solid geometry. A blended net would
// join the transparent queue and sort per-object against the boundary shell,
// which is the class of artefact this whole pass removed.
shader_type spatial;
render_mode cull_front, diffuse_burley;
uniform vec3 net_color : source_color = vec3(0.65, 0.78, 1.0);
uniform float cell_size = 0.13;
uniform float strand_width = 0.011;
uniform float emission_strength : hint_range(0.0, 4.0) = 0.35;
varying vec3 v_local;
varying vec3 v_local_normal;
void vertex() {
v_local = VERTEX;
v_local_normal = NORMAL;
}
// Distance to the nearest strand centre line of a square lattice.
float strand(vec2 p) {
vec2 d = abs(fract(p / cell_size) - 0.5) * cell_size;
return min(d.x, d.y);
}
void fragment() {
vec3 n = abs(normalize(v_local_normal));
// Project onto whichever plane this face lies in.
float d = n.x > max(n.y, n.z)
? strand(v_local.zy)
: (n.y > n.z ? strand(v_local.xz) : strand(v_local.xy));
if (d > strand_width) {
discard;
}
// Only back faces survive cull_front, and their normals point out of the
// pocket rather than into it, so flip them or the netting lights from
// behind and reads flat black.
if (!FRONT_FACING) {
NORMAL = -NORMAL;
}
ALBEDO = net_color * 0.25;
EMISSION = net_color * emission_strength;
ROUGHNESS = 0.65;
METALLIC = 0.1;
}
+1
View File
@@ -0,0 +1 @@
uid://bbueqcqp62gqo