From 1976149ce566d1fe5f33d7d7fbd86bdb35cbceb1 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Fri, 7 Aug 2026 11:47:21 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20KINETIC+HEAT=20chemistry=20row=20+=20se?= =?UTF-8?q?lf-heating=20burster=20=E2=80=94=20greybox=20build=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/audio/chem_sting_kinetic_heat_ph.wav | Bin 0 -> 15920 bytes .../chem_sting_kinetic_heat_ph.wav.import | 24 ++++++ scenes/arena.tscn | 7 +- scenes/burster.tscn | 23 ++++++ scripts/arena.gd | 21 +++++- scripts/chemistry_fx.gd | 29 ++++++++ scripts/chemistry_fx.gd.uid | 1 + scripts/fx_ring.gd.uid | 1 + scripts/grunt.gd | 70 ++++++++++++++---- scripts/hand.gd | 17 +++++ scripts/hand.gd.uid | 1 + scripts/hand_hud.gd.uid | 1 + scripts/spawner.gd | 8 +- tests/smoke.gd | 47 ++++++++++++ 14 files changed, 233 insertions(+), 17 deletions(-) create mode 100644 assets/audio/chem_sting_kinetic_heat_ph.wav create mode 100644 assets/audio/chem_sting_kinetic_heat_ph.wav.import create mode 100644 scenes/burster.tscn create mode 100644 scripts/chemistry_fx.gd create mode 100644 scripts/chemistry_fx.gd.uid create mode 100644 scripts/fx_ring.gd.uid create mode 100644 scripts/hand.gd.uid create mode 100644 scripts/hand_hud.gd.uid diff --git a/assets/audio/chem_sting_kinetic_heat_ph.wav b/assets/audio/chem_sting_kinetic_heat_ph.wav new file mode 100644 index 0000000000000000000000000000000000000000..65224658ee2df57d5d647fa8dc0490f5831c4152 GIT binary patch literal 15920 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 literal 0 HcmV?d00001 diff --git a/assets/audio/chem_sting_kinetic_heat_ph.wav.import b/assets/audio/chem_sting_kinetic_heat_ph.wav.import new file mode 100644 index 0000000..7faf68c --- /dev/null +++ b/assets/audio/chem_sting_kinetic_heat_ph.wav.import @@ -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 diff --git a/scenes/arena.tscn b/scenes/arena.tscn index f2ecdf0..f325205 100644 --- a/scenes/arena.tscn +++ b/scenes/arena.tscn @@ -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"] diff --git a/scenes/burster.tscn b/scenes/burster.tscn new file mode 100644 index 0000000..d3ae2cd --- /dev/null +++ b/scenes/burster.tscn @@ -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") diff --git a/scripts/arena.gd b/scripts/arena.gd index a7b5d01..d155075 100644 --- a/scripts/arena.gd +++ b/scripts/arena.gd @@ -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) diff --git a/scripts/chemistry_fx.gd b/scripts/chemistry_fx.gd new file mode 100644 index 0000000..97bc1a9 --- /dev/null +++ b/scripts/chemistry_fx.gd @@ -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)) diff --git a/scripts/chemistry_fx.gd.uid b/scripts/chemistry_fx.gd.uid new file mode 100644 index 0000000..3b623c4 --- /dev/null +++ b/scripts/chemistry_fx.gd.uid @@ -0,0 +1 @@ +uid://55wtetewo5wd diff --git a/scripts/fx_ring.gd.uid b/scripts/fx_ring.gd.uid new file mode 100644 index 0000000..e2026d3 --- /dev/null +++ b/scripts/fx_ring.gd.uid @@ -0,0 +1 @@ +uid://uq5254gd1260 diff --git a/scripts/grunt.gd b/scripts/grunt.gd index 32baa1e..8e88882 100644 --- a/scripts/grunt.gd +++ b/scripts/grunt.gd @@ -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) diff --git a/scripts/hand.gd b/scripts/hand.gd index 7a36b89..d168137 100644 --- a/scripts/hand.gd +++ b/scripts/hand.gd @@ -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 diff --git a/scripts/hand.gd.uid b/scripts/hand.gd.uid new file mode 100644 index 0000000..460f91f --- /dev/null +++ b/scripts/hand.gd.uid @@ -0,0 +1 @@ +uid://v8l1rui4tw4i diff --git a/scripts/hand_hud.gd.uid b/scripts/hand_hud.gd.uid new file mode 100644 index 0000000..2e02e78 --- /dev/null +++ b/scripts/hand_hud.gd.uid @@ -0,0 +1 @@ +uid://badphx2miqxlc diff --git a/scripts/spawner.gd b/scripts/spawner.gd index 4fc71a2..11fbc17 100644 --- a/scripts/spawner.gd +++ b/scripts/spawner.gd @@ -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) diff --git a/tests/smoke.gd b/tests/smoke.gd index 303d2f1..d0888b7 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -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