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 0000000..6522465 Binary files /dev/null and b/assets/audio/chem_sting_kinetic_heat_ph.wav differ 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