diff --git a/Game/objects/goal.tscn b/Game/objects/goal.tscn index 5e52c38c..bc05606d 100644 --- a/Game/objects/goal.tscn +++ b/Game/objects/goal.tscn @@ -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") diff --git a/Game/scripts/goal.gd b/Game/scripts/goal.gd index 0c8610a6..40bb20ec 100644 --- a/Game/scripts/goal.gd +++ b/Game/scripts/goal.gd @@ -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 diff --git a/Game/shaders/goal_net.gdshader b/Game/shaders/goal_net.gdshader new file mode 100644 index 00000000..d114eba1 --- /dev/null +++ b/Game/shaders/goal_net.gdshader @@ -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; +} diff --git a/Game/shaders/goal_net.gdshader.uid b/Game/shaders/goal_net.gdshader.uid new file mode 100644 index 00000000..31efabf9 --- /dev/null +++ b/Game/shaders/goal_net.gdshader.uid @@ -0,0 +1 @@ +uid://bbueqcqp62gqo