feat: KINETIC+HEAT chemistry row + self-heating burster — greybox build complete

Chemistry row *Spread* (canon CONCEPT.md § tag grammar): a heated
enemy slammed into another (knockback impact >= threshold, not a
walking bump) spreads its heat on contact and passes momentum, so
chains cascade with decaying force — chemistry feeding chemistry.
Authorship-loud feedback stack per canon: ~80ms hitstop (owned by the
hand, capped so chain procs can't lock the world; rides on top of
freeze/thaw states), the two tag hues meeting at the point of impact
(oriented fx, no proc text), and a per-pair audio sting (procedural
placeholder, _ph-marked per asset policy).

Burster: second enemy type, HEAT-tagged walking bomb (orange base,
literal tag mirror) — self-heats ~0.5/s toward threshold, big
detonation. Every 4th spawn. Detonations are now symmetric: they
damage enemies AND the mech in radius (one grammar on both sides;
heat play prices standing close). Grunt scene exports drive both
enemy types — no subclass.

Smoke gate extended: burster self-heat observed (retry-tolerant),
deterministic staged chemistry proc (heated striker slammed into cold
neighbor outside weapon range -> proc counter, heat spread). 3/3
green on nh3-dev headless 4.7.1. Known cosmetic: 2 ObjectDB instances
reported leaked at exit (constant, exit-order artifact — audio stream
alive at quit), not a runtime leak.
This commit is contained in:
2026-08-07 11:47:21 -07:00
parent 3d2410c8fd
commit 1976149ce5
14 changed files with 233 additions and 17 deletions
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c6t7uwmnlqaup"
path="res://.godot/imported/chem_sting_kinetic_heat_ph.wav-8ea5181b52abc6efbaead61e1d2c7753.sample"
[deps]
source_file="res://assets/audio/chem_sting_kinetic_heat_ph.wav"
dest_files=["res://.godot/imported/chem_sting_kinetic_heat_ph.wav-8ea5181b52abc6efbaead61e1d2c7753.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
+6 -1
View File
@@ -1,10 +1,11 @@
[gd_scene load_steps=8 format=3]
[gd_scene load_steps=9 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"]
[ext_resource type="Script" path="res://scripts/hand.gd" id="4"]
[ext_resource type="Script" path="res://scripts/hand_hud.gd" id="5"]
[ext_resource type="AudioStream" path="res://assets/audio/chem_sting_kinetic_heat_ph.wav" id="6"]
[sub_resource type="RectangleShape2D" id="wall_h"]
size = Vector2(640, 16)
@@ -53,6 +54,10 @@ position = Vector2(320, 180)
[node name="Spawner" type="Node2D" parent="."]
script = ExtResource("3")
[node name="Sting" type="AudioStreamPlayer" parent="."]
stream = ExtResource("6")
volume_db = -6.0
[node name="HUD" type="CanvasLayer" parent="."]
[node name="HullBack" type="ColorRect" parent="HUD"]
+23
View File
@@ -0,0 +1,23 @@
[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 = 11.0
[node name="Burster" type="CharacterBody2D"]
collision_layer = 4
collision_mask = 7
script = ExtResource("1")
speed = 45.0
hp = 5.0
contact_damage = 3.0
body_radius = 11.0
base_color = Color(0.6, 0.4, 0.25, 1)
self_heat_rate = 0.5
detonation_damage = 2.5
detonation_mech_damage = 15.0
detonation_radius = 80.0
[node name="Collision" type="CollisionShape2D" parent="."]
shape = SubResource("body")
+20 -1
View File
@@ -1,15 +1,34 @@
extends Node2D
## One greybox arena, one screen (640x360), fixed camera. Draws its own
## floor and wall slabs; tracks kills for instrumentation.
## floor and wall slabs; tracks kills and chemistry procs; conducts the
## authorship-loud chemistry feedback stack (canon: brief hitstop + the
## two tag hues meeting at the point of contact + a per-pair sting —
## no proc text, the light and the sting are the noun).
const CHEM_FX := preload("res://scripts/chemistry_fx.gd")
var kills: int = 0
var chem_procs: int = 0
func _ready() -> void:
add_to_group("arena")
$Spawner.grunt_spawned.connect(_on_grunt_spawned)
$Mech.hull_changed.connect(_on_hull_changed)
func chemistry_proc(at: Vector2, axis: Vector2) -> void:
chem_procs += 1
var hand := get_tree().get_first_node_in_group("hand")
if hand != null:
hand.hitstop()
var fx: Node2D = CHEM_FX.new()
fx.position = at
fx.axis = axis
add_child(fx)
$Sting.play()
func _on_grunt_spawned(grunt: Node) -> void:
grunt.died.connect(func() -> void: kills += 1)
+29
View File
@@ -0,0 +1,29 @@
extends Node2D
## KINETIC+HEAT chemistry proc marker: the two tag hues visibly meet
## at the point of impact (canon feedback stack — the light and the
## sting are the noun; no proc text, ever). Oriented along the impact
## axis: KINETIC steel-blue sweeps in from the striker side, HEAT
## orange flares out the struck side, white core where they touch.
var axis := Vector2.RIGHT
var duration := 0.28
var max_r := 26.0
var _t := 0.0
func _process(delta: float) -> void:
_t += delta
queue_redraw()
if _t >= duration:
queue_free()
func _draw() -> void:
var k := clampf(_t / duration, 0.0, 1.0)
var a := 1.0 - k
var r := lerpf(6.0, max_r, k)
var ang := axis.angle()
draw_arc(Vector2.ZERO, r, ang + PI - 1.2, ang + PI + 1.2, 20, Color(0.75, 0.85, 0.95, a), 3.0)
draw_arc(Vector2.ZERO, r * 0.8, ang - 1.2, ang + 1.2, 20, Color(0.95, 0.55, 0.2, a), 3.0)
draw_circle(Vector2.ZERO, lerpf(5.0, 1.0, k), Color(1, 1, 1, a))
+1
View File
@@ -0,0 +1 @@
uid://55wtetewo5wd
+1
View File
@@ -0,0 +1 @@
uid://uq5254gd1260
+56 -14
View File
@@ -1,13 +1,22 @@
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.
## 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).
## 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).
## 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
@@ -16,17 +25,31 @@ signal died
@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)
## > 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
@@ -34,15 +57,21 @@ 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
@@ -51,10 +80,21 @@ func _physics_process(delta: float) -> void:
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
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
@@ -87,6 +127,8 @@ func _detonate() -> void:
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
@@ -103,12 +145,12 @@ func _die() -> void:
func _draw() -> void:
var body := Color(0.45, 0.47, 0.4)
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, 8.0, body)
draw_circle(Vector2.ZERO, body_radius, 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)
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)
+17
View File
@@ -57,6 +57,9 @@ extends CanvasLayer
## In-band thaw acceleration: 1 = linear; higher hangs slow longer,
## then rushes to full speed.
@export var thaw_curve := 2.2
## Chemistry-proc hitstop length in real seconds (the hand owns time,
## so it owns the stop; capped so chain procs can't lock the world).
@export var hitstop_s := 0.08
@export_group("KINETIC card")
@export var kinetic_cost := 1
@@ -78,6 +81,7 @@ var _hit_progress := 0
var _last_ms := 0
var _release_from := 1.0
var _release_t := -1.0
var _hitstop_t := 0.0
# Slot order is the hand: Q = slot 1, E = slot 2.
var cards: Array[Dictionary] = []
@@ -140,6 +144,15 @@ func _process(_delta: float) -> void:
Engine.time_scale = lerpf(_release_from, 1.0, k)
if k >= 1.0:
_release_t = -1.0
# Hitstop rides on top of whatever time state is current (it can
# only slow, never speed up); expiry re-derives the proper state.
if _hitstop_t > 0.0:
_hitstop_t -= real_dt
if not get_tree().paused:
Engine.time_scale = minf(Engine.time_scale, 0.05)
if _hitstop_t <= 0.0:
_apply_time_state()
$HUD.queue_redraw()
@@ -164,6 +177,10 @@ func on_build_hit() -> void:
charges = mini(charges + 1, max_charges)
func hitstop(dur := -1.0) -> void:
_hitstop_t = minf(maxf(_hitstop_t, dur if dur > 0.0 else hitstop_s), 0.15)
func can_open() -> bool:
return not meter_mode or meter / meter_max >= band_unavailable
+1
View File
@@ -0,0 +1 @@
uid://v8l1rui4tw4i
+1
View File
@@ -0,0 +1 @@
uid://badphx2miqxlc
+7 -1
View File
@@ -5,13 +5,17 @@ extends Node2D
signal grunt_spawned(grunt: Node)
const GRUNT := preload("res://scenes/grunt.tscn")
const BURSTER := preload("res://scenes/burster.tscn")
@export var target_count := 8
@export var spawn_interval := 0.8
## Every Nth spawn is a burster.
@export var burster_every := 4
@export var spawn_rect := Rect2(32, 32, 576, 296)
@export var min_dist_from_mech := 120.0
var _timer := 0.0
var _spawned := 0
func _physics_process(delta: float) -> void:
@@ -24,7 +28,9 @@ func _physics_process(delta: float) -> void:
func _spawn() -> void:
var g := GRUNT.instantiate()
_spawned += 1
var scene := BURSTER if _spawned % burster_every == 0 else GRUNT
var g := scene.instantiate()
g.position = _pick_point()
add_child(g)
grunt_spawned.emit(g)
+47
View File
@@ -140,6 +140,7 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void:
_failures.append("heated grunt failed to detonate after release (heat %.1f)" % target.heat)
await _test_meter(hand)
await _test_chemistry(arena, mech)
func _test_meter(hand: Node) -> void:
@@ -194,6 +195,52 @@ func _test_meter(hand: Node) -> void:
await _until(func() -> bool: return Engine.time_scale >= 0.999, 3.0, "toggle-mode release ramp never reached full speed")
func _test_chemistry(arena: Node, mech: CharacterBody2D) -> void:
# Burster: a self-heating enemy spawns and its heat grows on its own.
# It may die to auto-fire or pop mid-observation, so retry a few.
var observed_growth := false
for attempt in 4:
if not await _until(func() -> bool: return get_tree().get_first_node_in_group("bursters") != null, 20.0, "no burster ever spawned"):
return
var b: Node2D = get_tree().get_first_node_in_group("bursters")
var h0: float = b.heat
for i in 30:
await get_tree().physics_frame
if is_instance_valid(b):
if b.heat > h0:
observed_growth = true
break
_failures.append("burster heat did not grow (%.2f -> %.2f)" % [h0, b.heat])
return
if not observed_growth:
_failures.append("never observed a burster long enough to confirm self-heat")
return
# Chemistry row KINETIC+HEAT, deterministic: stage a heated striker
# and a cold neighbor in the far corner (outside weapon range),
# slam the striker into the neighbor, expect the proc: heat spread,
# momentum passed, arena proc counter up.
var plain: Array[Node2D] = []
for e in get_tree().get_nodes_in_group("enemies"):
if e.self_heat_rate == 0.0 and not e._dead:
plain.append(e)
if plain.size() < 2:
_failures.append("not enough plain grunts alive to stage chemistry")
return
var striker := plain[0]
var struck := plain[1]
striker.global_position = Vector2(80.0, 80.0)
struck.global_position = Vector2(104.0, 80.0)
striker.heat = 1.0
struck.heat = 0.0
var procs0: int = arena.chem_procs
striker.hit(0.0, Vector2(500.0, 0.0))
if not await _until(func() -> bool: return arena.chem_procs > procs0, 5.0, "chemistry proc never fired on knocked-heated impact"):
return
if not is_instance_valid(struck) or struck.heat <= 0.0:
_failures.append("chemistry proc fired but heat did not spread to the struck enemy")
func _nearest_grunt(mech: CharacterBody2D) -> Node2D:
var best: Node2D = null
var best_d := 120.0 * 120.0