feat: tracer bullet — greybox arena, positioning piloting, auto-fire KINETIC weapon

One-screen 640x360 arena (walls, grid floor, fixed view), mech with
positioning-only movement (WASD/arrows, accel/decel, no aim), auto-fire
KINETIC hardpoint (nearest-target, visible range ring) firing slugs
that damage + knock back, untagged grunt trickle spawner with contact
damage, hull bar HUD, scene-reload on hull zero.

Headless smoke gate: tests/smoke.tscn asserts boot, piloting movement,
spawning, input-free auto-fire, grunt lethality, knockback displacement.
3/3 passes on nh3-dev headless 4.7.1.
This commit is contained in:
2026-08-06 22:21:10 -07:00
parent df9236f403
commit 380088c694
20 changed files with 512 additions and 0 deletions
+39
View File
@@ -7,13 +7,52 @@ config_version=5
[application]
config/name="U R M03"
run/main_scene="res://scenes/arena.tscn"
config/features=PackedStringArray("4.7")
[display]
window/size/viewport_width=640
window/size/viewport_height=360
window/size/window_width_override=1280
window/size/window_height_override=720
window/stretch/mode="viewport"
window/stretch/scale_mode="integer"
[input]
move_left={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_up={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_down={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[layer_names]
2d_physics/layer_1="walls"
2d_physics/layer_2="mech"
2d_physics/layer_3="enemies"
2d_physics/layer_4="projectiles"
[rendering]
textures/canvas_textures/default_texture_filter=0
+68
View File
@@ -0,0 +1,68 @@
[gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://scripts/arena.gd" id="1"]
[ext_resource type="PackedScene" path="res://scenes/mech.tscn" id="2"]
[ext_resource type="Script" path="res://scripts/spawner.gd" id="3"]
[sub_resource type="RectangleShape2D" id="wall_h"]
size = Vector2(640, 16)
[sub_resource type="RectangleShape2D" id="wall_v"]
size = Vector2(16, 360)
[node name="Arena" type="Node2D"]
script = ExtResource("1")
[node name="WallTop" type="StaticBody2D" parent="."]
position = Vector2(320, 8)
collision_layer = 1
collision_mask = 0
[node name="Collision" type="CollisionShape2D" parent="WallTop"]
shape = SubResource("wall_h")
[node name="WallBottom" type="StaticBody2D" parent="."]
position = Vector2(320, 352)
collision_layer = 1
collision_mask = 0
[node name="Collision" type="CollisionShape2D" parent="WallBottom"]
shape = SubResource("wall_h")
[node name="WallLeft" type="StaticBody2D" parent="."]
position = Vector2(8, 180)
collision_layer = 1
collision_mask = 0
[node name="Collision" type="CollisionShape2D" parent="WallLeft"]
shape = SubResource("wall_v")
[node name="WallRight" type="StaticBody2D" parent="."]
position = Vector2(632, 180)
collision_layer = 1
collision_mask = 0
[node name="Collision" type="CollisionShape2D" parent="WallRight"]
shape = SubResource("wall_v")
[node name="Mech" parent="." instance=ExtResource("2")]
position = Vector2(320, 180)
[node name="Spawner" type="Node2D" parent="."]
script = ExtResource("3")
[node name="HUD" type="CanvasLayer" parent="."]
[node name="HullBack" type="ColorRect" parent="HUD"]
offset_left = 8.0
offset_top = 8.0
offset_right = 128.0
offset_bottom = 14.0
color = Color(0.06, 0.07, 0.08, 1)
[node name="HullFill" type="ColorRect" parent="HUD"]
offset_left = 9.0
offset_top = 9.0
offset_right = 127.0
offset_bottom = 13.0
color = Color(0.55, 0.8, 0.55, 1)
+14
View File
@@ -0,0 +1,14 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/grunt.gd" id="1"]
[sub_resource type="CircleShape2D" id="body"]
radius = 8.0
[node name="Grunt" type="CharacterBody2D"]
collision_layer = 4
collision_mask = 7
script = ExtResource("1")
[node name="Collision" type="CollisionShape2D" parent="."]
shape = SubResource("body")
+15
View File
@@ -0,0 +1,15 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/kinetic_projectile.gd" id="1"]
[sub_resource type="CircleShape2D" id="body"]
radius = 4.0
[node name="KineticProjectile" type="Area2D"]
collision_layer = 8
collision_mask = 5
monitorable = false
script = ExtResource("1")
[node name="Collision" type="CollisionShape2D" parent="."]
shape = SubResource("body")
+18
View File
@@ -0,0 +1,18 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/mech.gd" id="1"]
[ext_resource type="Script" path="res://scripts/kinetic_weapon.gd" id="2"]
[sub_resource type="RectangleShape2D" id="body"]
size = Vector2(20, 24)
[node name="Mech" type="CharacterBody2D"]
collision_layer = 2
collision_mask = 5
script = ExtResource("1")
[node name="Collision" type="CollisionShape2D" parent="."]
shape = SubResource("body")
[node name="Weapon" type="Node2D" parent="."]
script = ExtResource("2")
+31
View File
@@ -0,0 +1,31 @@
extends Node2D
## One greybox arena, one screen (640x360), fixed camera. Draws its own
## floor and wall slabs; tracks kills for instrumentation.
var kills: int = 0
func _ready() -> void:
$Spawner.grunt_spawned.connect(_on_grunt_spawned)
$Mech.hull_changed.connect(_on_hull_changed)
func _on_grunt_spawned(grunt: Node) -> void:
grunt.died.connect(func() -> void: kills += 1)
func _on_hull_changed(hull: float, hull_max: float) -> void:
$HUD/HullFill.size.x = 118.0 * (hull / hull_max)
func _draw() -> void:
draw_rect(Rect2(0, 0, 640, 360), Color(0.1, 0.11, 0.13))
for x in range(0, 641, 32):
draw_line(Vector2(x, 0), Vector2(x, 360), Color(0.13, 0.145, 0.17))
for y in range(0, 361, 32):
draw_line(Vector2(0, y), Vector2(640, y), Color(0.13, 0.145, 0.17))
var wall := Color(0.3, 0.32, 0.36)
draw_rect(Rect2(0, 0, 640, 16), wall)
draw_rect(Rect2(0, 344, 640, 16), wall)
draw_rect(Rect2(0, 0, 16, 360), wall)
draw_rect(Rect2(624, 0, 16, 360), wall)
+1
View File
@@ -0,0 +1 @@
uid://cw1g4kdg6hn82
+58
View File
@@ -0,0 +1,58 @@
extends CharacterBody2D
## Untagged grunt — the Act-1 learning-language enemy. Walks at the
## mech, deals contact damage on a tick. Untagged = neutral hue, no
## tag behavior. Knockback state lives here so KINETIC (and later
## chemistry) has something to shove.
signal died
@export var speed := 70.0
@export var hp := 3.0
@export var contact_damage := 5.0
@export var contact_interval := 0.5
@export var knockback_decay := 5.0
var _knockback := Vector2.ZERO
var _contact_cd := 0.0
var _flash := 0.0
var _mech: Node2D
func _ready() -> void:
add_to_group("enemies")
_mech = get_tree().get_first_node_in_group("mech")
func _physics_process(delta: float) -> void:
_contact_cd -= delta
var seek := Vector2.ZERO
if is_instance_valid(_mech):
seek = (_mech.global_position - global_position).normalized() * speed
_knockback = _knockback.lerp(Vector2.ZERO, 1.0 - exp(-knockback_decay * delta))
velocity = seek + _knockback
move_and_slide()
for i in get_slide_collision_count():
var collider := get_slide_collision(i).get_collider()
if collider == _mech and _contact_cd <= 0.0:
_mech.take_damage(contact_damage)
_contact_cd = contact_interval
if _flash > 0.0:
_flash -= delta
queue_redraw()
func hit(damage: float, kb: Vector2) -> void:
hp -= damage
_knockback += kb
_flash = 0.12
queue_redraw()
if hp <= 0.0:
died.emit()
queue_free()
func _draw() -> void:
var body := Color(0.95, 0.95, 0.95) if _flash > 0.0 else Color(0.45, 0.47, 0.4)
draw_circle(Vector2.ZERO, 8.0, body)
+1
View File
@@ -0,0 +1 @@
uid://k5sas40w41a6
+36
View File
@@ -0,0 +1,36 @@
extends Area2D
## KINETIC slug: damage plus knockback along its travel direction.
## Knockback is the load-bearing part — KINETIC is the one tag that
## touches the piloting layer, and the HEAT+KINETIC chemistry row
## (next slice) rides on impacts this weapon causes.
@export var speed := 420.0
@export var damage := 1.0
@export var knockback := 220.0
@export var lifetime := 0.8
var direction := Vector2.RIGHT
func _ready() -> void:
add_to_group("projectiles")
rotation = direction.angle()
body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
position += direction * speed * delta
lifetime -= delta
if lifetime <= 0.0:
queue_free()
func _on_body_entered(body: Node) -> void:
if body.is_in_group("enemies"):
body.hit(damage, direction * knockback)
queue_free()
func _draw() -> void:
# KINETIC hue: pale steel-blue (placeholder — tag colors unassigned in canon).
draw_rect(Rect2(-5, -1.5, 10, 3), Color(0.75, 0.85, 0.95))
+1
View File
@@ -0,0 +1 @@
uid://ose3uhxglar
+44
View File
@@ -0,0 +1,44 @@
extends Node2D
## Auto-fire KINETIC hardpoint. No player input ever touches this —
## the build expresses itself while the pilot only positions. Draws its
## own range ring so the kill zone is legible (positioning <-> build bet).
const PROJECTILE := preload("res://scenes/kinetic_projectile.tscn")
@export var fire_interval := 0.4
@export var range_px := 220.0
var _cooldown := 0.0
func _physics_process(delta: float) -> void:
_cooldown -= delta
if _cooldown > 0.0:
return
var target := _nearest_enemy()
if target == null:
return
_fire(target)
_cooldown = fire_interval
func _nearest_enemy() -> Node2D:
var best: Node2D = null
var best_d := range_px * range_px
for e in get_tree().get_nodes_in_group("enemies"):
var d: float = global_position.distance_squared_to(e.global_position)
if d < best_d:
best_d = d
best = e
return best
func _fire(target: Node2D) -> void:
var p := PROJECTILE.instantiate()
p.direction = (target.global_position - global_position).normalized()
p.position = global_position
owner.get_parent().add_child(p)
func _draw() -> void:
draw_arc(Vector2.ZERO, range_px, 0.0, TAU, 64, Color(0.55, 0.7, 0.85, 0.18), 1.0)
+1
View File
@@ -0,0 +1 @@
uid://dn6fsoa45vgu1
+37
View File
@@ -0,0 +1,37 @@
class_name Mech
extends CharacterBody2D
## M03, greybox slab. Piloting is positioning ONLY (canon: no aim, no
## combos) — movement input is the entire control surface.
signal hull_changed(hull: float, hull_max: float)
@export var move_speed := 160.0
@export var accel := 900.0
@export var decel := 1200.0
@export var hull_max := 100.0
var hull: float
func _ready() -> void:
add_to_group("mech")
hull = hull_max
func _physics_process(delta: float) -> void:
var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
var rate := accel if dir != Vector2.ZERO else decel
velocity = velocity.move_toward(dir * move_speed, rate * delta)
move_and_slide()
func take_damage(amount: float) -> void:
hull = maxf(hull - amount, 0.0)
hull_changed.emit(hull, hull_max)
if hull <= 0.0:
get_tree().reload_current_scene()
func _draw() -> void:
draw_rect(Rect2(-10, -12, 20, 24), Color(0.82, 0.84, 0.88))
draw_rect(Rect2(-6, -6, 12, 12), Color(0.55, 0.6, 0.68))
+1
View File
@@ -0,0 +1 @@
uid://cyw4tmaglsdu4
+44
View File
@@ -0,0 +1,44 @@
extends Node2D
## Keeps a floor count of grunts alive, spawning at the arena's inset
## edges. Endless trickle — the tracer needs targets, not waves.
signal grunt_spawned(grunt: Node)
const GRUNT := preload("res://scenes/grunt.tscn")
@export var target_count := 8
@export var spawn_interval := 0.8
@export var spawn_rect := Rect2(32, 32, 576, 296)
@export var min_dist_from_mech := 120.0
var _timer := 0.0
func _physics_process(delta: float) -> void:
_timer -= delta
if _timer > 0.0:
return
_timer = spawn_interval
if get_tree().get_nodes_in_group("enemies").size() < target_count:
_spawn()
func _spawn() -> void:
var g := GRUNT.instantiate()
g.position = _pick_point()
add_child(g)
grunt_spawned.emit(g)
func _pick_point() -> Vector2:
var mech := get_tree().get_first_node_in_group("mech") as Node2D
var p := Vector2.ZERO
for i in 12:
match randi() % 4:
0: p = Vector2(randf_range(spawn_rect.position.x, spawn_rect.end.x), spawn_rect.position.y)
1: p = Vector2(randf_range(spawn_rect.position.x, spawn_rect.end.x), spawn_rect.end.y)
2: p = Vector2(spawn_rect.position.x, randf_range(spawn_rect.position.y, spawn_rect.end.y))
3: p = Vector2(spawn_rect.end.x, randf_range(spawn_rect.position.y, spawn_rect.end.y))
if mech == null or p.distance_to(mech.global_position) >= min_dist_from_mech:
return p
return p
+1
View File
@@ -0,0 +1 @@
uid://bo1kuwuh0xjyb
+95
View File
@@ -0,0 +1,95 @@
extends Node
## Headless smoke test for the tracer bullet. Run:
## godot --headless --path . res://tests/smoke.tscn
## Asserts: arena boots, piloting moves the mech, grunts spawn, the
## weapon auto-fires with no input, a grunt dies, knockback displaces.
## Exit 0 = pass, 1 = fail.
const ARENA := preload("res://scenes/arena.tscn")
var _failures: Array[String] = []
var _done := false
func _ready() -> void:
get_tree().create_timer(25.0).timeout.connect(_on_global_timeout)
await _run()
_finish()
func _on_global_timeout() -> void:
if not _done:
_failures.append("global 25s timeout — test hung")
_finish()
func _run() -> void:
var arena := ARENA.instantiate()
add_child(arena)
await get_tree().physics_frame
await get_tree().physics_frame
var mech := arena.get_node_or_null("Mech") as CharacterBody2D
if mech == null:
_failures.append("no Mech node in arena")
return
# Piloting: press right, mech must move right.
var x0 := mech.global_position.x
Input.action_press("move_right")
for i in 30:
await get_tree().physics_frame
Input.action_release("move_right")
if mech.global_position.x <= x0 + 10.0:
_failures.append("piloting: mech did not move right (x %.1f -> %.1f)" % [x0, mech.global_position.x])
# Spawner: grunts appear.
if not await _until(func() -> bool: return get_tree().get_nodes_in_group("enemies").size() > 0, 5.0, "no grunts spawned"):
return
# Auto-fire: a projectile appears with zero input.
await _until(func() -> bool: return get_tree().get_nodes_in_group("projectiles").size() > 0, 5.0, "weapon never auto-fired")
# Lethality: at least one grunt dies to auto-fire alone.
await _until(func() -> bool: return arena.kills >= 1, 10.0, "no grunt died to auto-fire")
# Knockback: a zero-damage hit must displace a grunt along the hit
# direction. Probe the healthiest grunt — one mid-death from
# auto-fire would vanish under the probe and fake a failure.
var g: CharacterBody2D = null
for cand in get_tree().get_nodes_in_group("enemies"):
if g == null or cand.hp > g.hp:
g = cand
if g != null:
var gx := g.global_position.x
g.hit(0.0, Vector2(400, 0))
for i in 5:
await get_tree().physics_frame
if not is_instance_valid(g):
_failures.append("knockback probe grunt died to a zero-damage hit")
elif g.global_position.x <= gx + 2.0:
_failures.append("knockback: grunt did not displace (x %.1f -> %.1f)" % [gx, g.global_position.x])
func _until(pred: Callable, timeout: float, what: String) -> bool:
var t := 0.0
while t < timeout:
if pred.call():
return true
await get_tree().physics_frame
t += get_physics_process_delta_time()
_failures.append("timeout: " + what)
return false
func _finish() -> void:
if _done:
return
_done = true
if _failures.is_empty():
print("SMOKE PASS")
get_tree().quit(0)
else:
for f in _failures:
printerr("SMOKE FAIL: " + f)
get_tree().quit(1)
+1
View File
@@ -0,0 +1 @@
uid://qcvs3eu210cv
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/smoke.gd" id="1"]
[node name="Smoke" type="Node"]
script = ExtResource("1")