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:
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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://55wtetewo5wd
|
||||
@@ -0,0 +1 @@
|
||||
uid://uq5254gd1260
|
||||
+56
-14
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://v8l1rui4tw4i
|
||||
@@ -0,0 +1 @@
|
||||
uid://badphx2miqxlc
|
||||
+7
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user