mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
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:
+1
-12
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbueqcqp62gqo
|
||||
Reference in New Issue
Block a user