Files
urm03/scripts/grunt.gd
T
vh 4ae0876e34 feat: salvage-pickup loop — kill-fed charges, scrap drops, salvage-gated card levels
Per canon decisions 2026-08-07 (advisor CONCEPT.md f12cb15, falsifier
response): charges now generate on kills (kills_per_charge, chemistry
chain kills feed the hand; per-hit generation removed). Kills drop
scrap as physical pickups (amber diamond, magnet radius 70, drift to
mech, 20s fade; grunt 1 / burster 3) — collection is the positioning
reward gradient. Cumulative collected scrap gates card levels 1-3
(thresholds 12/30; per-level radius x1.25/x1.5 and power x1.15/x1.5 —
HEAT L3 hits one-cast detonation threshold). HUD: level notches under
slots + scrap counter. Salvage spawn is call_deferred: death can fire
inside a physics signal flush where Area2D setup is blocked.

Smoke gate: charge accrual now kill-fed, salvage drop + collection +
level gating phases added, heat-target selection made retry-tolerant.
4/4 green on nh3-dev headless 4.7.1.
2026-08-07 12:46:59 -07:00

158 lines
4.7 KiB
GDScript

extends CharacterBody2D
## Enemy body — used by both greybox enemy types via scene exports:
## the untagged grunt (neutral hue, no tag behavior of its own) and
## the self-heating burster (self_heat_rate > 0: HEAT-tagged walking
## bomb, orange base per the one-color legibility floor — the literal
## tag mirror made playable: its own mechanic is your ammo if you
## knock it into the swarm before it pops).
##
## Heat: accumulates, glows, detonates at threshold (HEAT's signature).
## Detonation is symmetric — it damages enemies AND the mech in radius
## (one grammar on both sides; your heat play prices standing close).
## Detonation deals no knockback: moving things is KINETIC's verb.
##
## Chemistry row KINETIC+HEAT (*Spread*): a heated enemy slammed into
## another (knockback impact, not a walking bump) spreads its heat on
## contact, passes momentum, and fires the authorship-loud feedback
## stack via the arena. Struck enemies are heated AND shoved, so
## chains can cascade — chemistry feeding chemistry, decaying with
## each hop.
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 var body_radius := 8.0
@export var base_color := Color(0.45, 0.47, 0.4)
@export var salvage_value := 1
## > 0 makes this a burster: heat/second it applies to itself.
@export var self_heat_rate := 0.0
@export_group("Heat status")
@export var heat_threshold := 3.0
@export var detonation_damage := 1.5
@export var detonation_mech_damage := 6.0
@export var detonation_radius := 56.0
@export_group("Chemistry KINETIC+HEAT")
## Minimum knockback speed for an impact to count as "knocked into".
@export var chem_min_knockback := 120.0
## Fraction of the striker's heat the struck enemy receives.
@export var chem_spread_factor := 1.0
## Fraction of the striker's knockback passed to the struck enemy.
@export var chem_momentum_factor := 0.45
const FX_RING := preload("res://scripts/fx_ring.gd")
var heat := 0.0
var _knockback := Vector2.ZERO
var _contact_cd := 0.0
var _chem_cd := 0.0
var _flash := 0.0
var _dead := false
var _mech: Node2D
func _ready() -> void:
add_to_group("enemies")
if self_heat_rate > 0.0:
add_to_group("bursters")
_mech = get_tree().get_first_node_in_group("mech")
func _physics_process(delta: float) -> void:
if self_heat_rate > 0.0:
heat += self_heat_rate * delta
queue_redraw()
if heat >= heat_threshold:
_detonate()
return
_contact_cd -= delta
_chem_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 col := get_slide_collision(i)
var collider := col.get_collider()
if collider == _mech:
if _contact_cd <= 0.0:
_mech.take_damage(contact_damage)
_contact_cd = contact_interval
elif collider is CharacterBody2D and collider.is_in_group("enemies"):
if heat > 0.0 and _chem_cd <= 0.0 and _knockback.length() >= chem_min_knockback:
_chem_cd = 0.12
collider.add_heat(heat * chem_spread_factor)
collider.hit(0.0, _knockback * chem_momentum_factor)
var arena := get_tree().get_first_node_in_group("arena")
if arena != null:
var axis: Vector2 = (collider.global_position - global_position).normalized()
arena.chemistry_proc(col.get_position(), axis)
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)
if is_instance_valid(_mech) and global_position.distance_to(_mech.global_position) <= detonation_radius:
_mech.take_damage(detonation_mech_damage)
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 := base_color
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, body_radius, body)
if heat_k > 0.0:
draw_arc(Vector2.ZERO, body_radius + 2.0, 0.0, TAU, 24, Color(0.95, 0.55, 0.2, 0.4 + heat_k * 0.5), 1.5)