5ba185f775
Operator asked for placeholder sprites for playtest feel. Actors now render the hand-authored pixel maps (tools/aseprite/maps -> assets/ sprites_ph, committed) via code-created Sprite2D — no scene texture UIDs to hand-author. Dynamic feedback preserved: grunt/burster heat tint + hit flash drive sprite.modulate, heat-aura ring stays a _draw overlay; salvage fade drives modulate.a. Arena floor/walls, HUD, and FX stay primitives. CLAUDE.md greybox-art note updated (was 'no sprites'); real art (watercolor scenery + pixel sprites) still later. Smoke gate 3/3 green on nh3-dev headless 4.7.1.
177 lines
5.4 KiB
GDScript
177 lines
5.4 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
|
|
## Placeholder sprite; overridden per scene (grunt vs burster).
|
|
@export var sprite_path := "res://assets/sprites_ph/grunt_ph.png"
|
|
## > 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
|
|
var _sprite: Sprite2D
|
|
|
|
|
|
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")
|
|
_sprite = Sprite2D.new()
|
|
_sprite.texture = load(sprite_path)
|
|
add_child(_sprite)
|
|
_update_tint()
|
|
|
|
|
|
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()
|
|
_update_tint()
|
|
|
|
|
|
## The sprite carries the dynamic feedback the primitive used to: heat
|
|
## tints the body toward orange (HEAT's read), a hit flashes it bright.
|
|
## The heat-aura ring stays a _draw overlay (below the sprite).
|
|
func _update_tint() -> void:
|
|
if _sprite == null:
|
|
return
|
|
if _flash > 0.0:
|
|
_sprite.modulate = Color(2.5, 2.5, 2.5)
|
|
return
|
|
var heat_k := clampf(heat / heat_threshold, 0.0, 1.0)
|
|
_sprite.modulate = Color(1, 1, 1).lerp(Color(1.5, 0.75, 0.4), heat_k)
|
|
|
|
|
|
func hit(damage: float, kb: Vector2) -> void:
|
|
if _dead:
|
|
return
|
|
hp -= damage
|
|
_knockback += kb
|
|
_flash = 0.12
|
|
_update_tint()
|
|
queue_redraw()
|
|
if hp <= 0.0:
|
|
_die()
|
|
|
|
|
|
func add_heat(amount: float) -> void:
|
|
if _dead:
|
|
return
|
|
heat += amount
|
|
_update_tint()
|
|
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:
|
|
# Body is now the sprite; _draw carries only the heat-aura ring
|
|
# (renders under the sprite, extends past it — a visible glow).
|
|
var heat_k := clampf(heat / heat_threshold, 0.0, 1.0)
|
|
if heat_k > 0.0:
|
|
draw_arc(Vector2.ZERO, body_radius + 3.0, 0.0, TAU, 24, Color(0.95, 0.55, 0.2, 0.4 + heat_k * 0.5), 1.5)
|