Files
urm03/scripts/grunt.gd
T
vh c340b7bc48 feat: card layer — 2-slot hand, build-generated charges, card-time freeze/dilate
Hand (CanvasLayer, PROCESS_MODE_ALWAYS): Space toggles the hand, Q/E
play slot 1/2, charges accrue from auto-fire hits (4 hits/charge, cap
5). KINETIC card: radial shockwave shove + light damage. HEAT card:
paints heat status in a radius; heat accumulates, glows, detonates at
threshold (damage only, no knockback — tag purity). Freeze/dilate is
an exposed knob: [ and ] step dilation 0.0-0.3 live.

Freeze uses SceneTree.paused, not Engine.time_scale = 0: probed on
4.7 headless — time_scale 0 still steps physics with delta 0, so
delta-independent logic (heat thresholds, contact callbacks) kept
firing while nominally frozen. Dilation > 0 uses time_scale slow-mo.
Hand._exit_tree restores pause + time_scale so hull-zero reloads can
never strand a frozen world.

Smoke gate extended: charge accrual from build hits, pause-on-open at
knob 0, HEAT x2 spend + threshold, detonation on release. 3/3 green
on nh3-dev headless 4.7.1.
2026-08-06 22:42:46 -07:00

115 lines
2.8 KiB
GDScript

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 of its own; heat is a status OTHERS put on it.
##
## Heat: accumulates, glows, detonates at threshold (HEAT's signature).
## The threshold check runs in physics so detonations resolve when time
## flows — stack heat while frozen, release, pop. Detonation is damage
## only, zero knockback: moving things is KINETIC's verb, not HEAT's
## (tag purity keeps the chemistry legible).
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
@export_group("Heat status")
@export var heat_threshold := 3.0
@export var detonation_damage := 1.5
@export var detonation_radius := 56.0
const FX_RING := preload("res://scripts/fx_ring.gd")
var heat := 0.0
var _knockback := Vector2.ZERO
var _contact_cd := 0.0
var _flash := 0.0
var _dead := false
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:
if heat >= heat_threshold:
_detonate()
return
_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:
if _dead:
return
hp -= damage
_knockback += kb
_flash = 0.12
queue_redraw()
if hp <= 0.0:
_die()
func add_heat(amount: float) -> void:
if _dead:
return
heat += amount
queue_redraw()
func _detonate() -> void:
if _dead:
return
for e in get_tree().get_nodes_in_group("enemies"):
if e == self:
continue
if global_position.distance_to(e.global_position) <= detonation_radius:
e.hit(detonation_damage, Vector2.ZERO)
var ring: Node2D = FX_RING.new()
ring.position = global_position
ring.max_radius = detonation_radius
get_parent().add_child(ring)
_die()
func _die() -> void:
if _dead:
return
_dead = true
died.emit()
queue_free()
func _draw() -> void:
var body := Color(0.45, 0.47, 0.4)
var heat_k := clampf(heat / heat_threshold, 0.0, 1.0)
if heat_k > 0.0:
body = body.lerp(Color(0.95, 0.5, 0.15), heat_k * 0.8)
if _flash > 0.0:
body = Color(0.95, 0.95, 0.95)
draw_circle(Vector2.ZERO, 8.0, body)
if heat_k > 0.0:
draw_arc(Vector2.ZERO, 10.0, 0.0, TAU, 24, Color(0.95, 0.55, 0.2, 0.4 + heat_k * 0.5), 1.5)