feat: salvage-pickup loop — kill-fed charges, scrap drops, salvage-gated card levels
Per canon decisions 2026-08-07 (advisor CONCEPT.md f12cb15, falsifier response): charges now generate on kills (kills_per_charge, chemistry chain kills feed the hand; per-hit generation removed). Kills drop scrap as physical pickups (amber diamond, magnet radius 70, drift to mech, 20s fade; grunt 1 / burster 3) — collection is the positioning reward gradient. Cumulative collected scrap gates card levels 1-3 (thresholds 12/30; per-level radius x1.25/x1.5 and power x1.15/x1.5 — HEAT L3 hits one-cast detonation threshold). HUD: level notches under slots + scrap counter. Salvage spawn is call_deferred: death can fire inside a physics signal flush where Area2D setup is blocked. Smoke gate: charge accrual now kill-fed, salvage drop + collection + level gating phases added, heat-target selection made retry-tolerant. 4/4 green on nh3-dev headless 4.7.1.
This commit is contained in:
+25
-17
@@ -16,24 +16,24 @@ greybox falsifier (ROADMAP.md v1) that tests the design's three core bets.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-08-07:_
|
||||
_As of 2026-08-07 (post-falsifier):_
|
||||
|
||||
- **Greybox build COMPLETE** at `1976149`, pushed to gitea (vh/urm03) —
|
||||
all five build capabilities live: arena + positioning-only piloting,
|
||||
auto-fire KINETIC weapon, cards + Freeze-charge meter + thaw/release
|
||||
ramps, KINETIC+HEAT chemistry row with authorship-loud feedback stack
|
||||
(hitstop + hues meeting + `_ph` sting), grunt swarm + self-heating
|
||||
burster. Headless smoke gate green 3/3.
|
||||
- **Awaiting the falsification playtest** (ROADMAP capability 6):
|
||||
operator verdicts on the three bets — positioning↔build interaction,
|
||||
card-freeze rhythm, chemistry delight. Early signal on bet 2 is
|
||||
positive: freeze meter + release ramp "feels very good" / "starting to
|
||||
feel game-like" (operator, 2026-08-07). Tuning everywhere still open.
|
||||
- Operator hit a pull conflict on the Mac (untracked `.uid` files vs
|
||||
`1976149`) — fix being applied this session; an operator-surfaced
|
||||
issue is expected next.
|
||||
- After verdicts: draft the CONCEPT.md canon entries in the advisor repo
|
||||
(freeze-meter ratification + whatever else the playtest settles).
|
||||
- **Greybox build complete AND falsifier verdicts rendered** (ROADMAP
|
||||
v1 target met through capability 6). Verdicts: bet 2 (card-freeze
|
||||
rhythm) CONFIRMED; bet 1 (positioning) shaky → salvage-pickup loop
|
||||
adopted and built; bet 3 (chemistry) inconclusive → in-card
|
||||
randomness parked to belt stage.
|
||||
- **Post-falsifier loop built**: charges generate on kills; kills drop
|
||||
scrap pickups (magnet radius, fade); collected scrap gates card
|
||||
levels 1→3 (thresholds + per-level radius/power scaling, HEAT L3
|
||||
reaches one-cast threshold). Smoke gate green 4/4.
|
||||
- Canon amendments landed in the advisor repo at `f12cb15`
|
||||
(**local-only — that repo has NO git remote**): Freeze charge
|
||||
ratified, kill-charges + salvage-gated levels decided, randomness
|
||||
parked.
|
||||
- Everything is knobs; operator says "everything needs tuning" — no
|
||||
tuning pass done yet. v1.0 cut not yet made (operator sign-off
|
||||
needed; ROADMAP v1 target arguably met).
|
||||
|
||||
## Recent decisions
|
||||
|
||||
@@ -51,6 +51,10 @@ _As of 2026-08-07:_
|
||||
- `[2026-08-06]` Placeholder tag hues: KINETIC pale steel-blue, HEAT
|
||||
orange — canon assigns no tag colors yet (naming/color pass parked
|
||||
post-greybox).
|
||||
- `[2026-08-07]` Falsifier verdicts + resulting design decisions live
|
||||
in advisor-repo CONCEPT.md (§ loop stack amendments, decisions-log
|
||||
rows, open-question #0 closure) at `f12cb15` — design rationale is
|
||||
NOT duplicated here; that log owns it.
|
||||
|
||||
## Tried and abandoned
|
||||
|
||||
@@ -63,3 +67,7 @@ _As of 2026-08-07:_
|
||||
the Mac editor generated its own untracked copies and the next
|
||||
`git pull` refused to merge over them. Import headless before
|
||||
committing so `.uid` files ride the same commit as their scripts.
|
||||
- `[2026-08-07]` Spawning physics nodes (Area2D) directly inside a
|
||||
physics signal flush (projectile `body_entered` → death → spawn) —
|
||||
Godot blocks collision setup mid-flush. Spawn via
|
||||
`add_child.call_deferred()` from any death/hit signal path.
|
||||
|
||||
@@ -14,6 +14,7 @@ hp = 5.0
|
||||
contact_damage = 3.0
|
||||
body_radius = 11.0
|
||||
base_color = Color(0.6, 0.4, 0.25, 1)
|
||||
salvage_value = 3
|
||||
self_heat_rate = 0.5
|
||||
detonation_damage = 2.5
|
||||
detonation_mech_damage = 15.0
|
||||
|
||||
+16
-1
@@ -6,6 +6,7 @@ extends Node2D
|
||||
## no proc text, the light and the sting are the noun).
|
||||
|
||||
const CHEM_FX := preload("res://scripts/chemistry_fx.gd")
|
||||
const SALVAGE := preload("res://scripts/salvage.gd")
|
||||
|
||||
var kills: int = 0
|
||||
var chem_procs: int = 0
|
||||
@@ -30,7 +31,21 @@ func chemistry_proc(at: Vector2, axis: Vector2) -> void:
|
||||
|
||||
|
||||
func _on_grunt_spawned(grunt: Node) -> void:
|
||||
grunt.died.connect(func() -> void: kills += 1)
|
||||
grunt.died.connect(func() -> void: _on_grunt_died(grunt))
|
||||
|
||||
|
||||
func _on_grunt_died(grunt: Node2D) -> void:
|
||||
# died fires before queue_free, so position/value are still valid.
|
||||
kills += 1
|
||||
var hand := get_tree().get_first_node_in_group("hand")
|
||||
if hand != null:
|
||||
hand.on_kill()
|
||||
var s: Area2D = SALVAGE.new()
|
||||
s.position = grunt.global_position
|
||||
s.value = grunt.salvage_value
|
||||
# Deferred: death can fire inside a physics signal flush (projectile
|
||||
# body_entered), where Area2D collision setup is blocked.
|
||||
add_child.call_deferred(s)
|
||||
|
||||
|
||||
func _on_hull_changed(hull: float, hull_max: float) -> void:
|
||||
|
||||
@@ -27,6 +27,7 @@ signal died
|
||||
@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
|
||||
## > 0 makes this a burster: heat/second it applies to itself.
|
||||
@export var self_heat_rate := 0.0
|
||||
|
||||
|
||||
+41
-13
@@ -33,9 +33,20 @@ extends CanvasLayer
|
||||
@export var dilation_scale := 0.0
|
||||
@export var dilation_step := 0.05
|
||||
@export var dilation_max := 0.3
|
||||
@export var hits_per_charge := 4
|
||||
@export var kills_per_charge := 2
|
||||
@export var max_charges := 5
|
||||
|
||||
@export_group("Salvage / card levels")
|
||||
## Cumulative collected scrap gates card levels (canon 2026-08-07:
|
||||
## same-currency rule — this is the run-timescale reading of the same
|
||||
## scrap the meta will bank; it is NOT a separate XP).
|
||||
@export var level2_salvage := 12
|
||||
@export var level3_salvage := 30
|
||||
## Per-level scaling, index = level - 1. Radius grows reach; power
|
||||
## scales impulse/heat (HEAT at L3 reaches one-cast threshold).
|
||||
@export var level_radius_scale: Array[float] = [1.0, 1.25, 1.5]
|
||||
@export var level_power_scale: Array[float] = [1.0, 1.15, 1.5]
|
||||
|
||||
@export_group("Freeze meter")
|
||||
## Seconds of open-hand time a full meter holds.
|
||||
@export var meter_max := 3.0
|
||||
@@ -77,7 +88,8 @@ const FX_RING := preload("res://scripts/fx_ring.gd")
|
||||
var charges := 0
|
||||
var hand_open := false
|
||||
var meter := 0.0
|
||||
var _hit_progress := 0
|
||||
var salvage_total := 0
|
||||
var _kill_progress := 0
|
||||
var _last_ms := 0
|
||||
var _release_from := 1.0
|
||||
var _release_t := -1.0
|
||||
@@ -170,13 +182,23 @@ func _input(event: InputEvent) -> void:
|
||||
_apply_time_state()
|
||||
|
||||
|
||||
func on_build_hit() -> void:
|
||||
_hit_progress += 1
|
||||
if _hit_progress >= hits_per_charge:
|
||||
_hit_progress = 0
|
||||
func on_kill() -> void:
|
||||
# Charges generate on kills — build-generated (the build does the
|
||||
# killing), and chemistry-chain kills feed the hand (canon 2026-08-07).
|
||||
_kill_progress += 1
|
||||
if _kill_progress >= kills_per_charge:
|
||||
_kill_progress = 0
|
||||
charges = mini(charges + 1, max_charges)
|
||||
|
||||
|
||||
func add_salvage(value: int) -> void:
|
||||
salvage_total += value
|
||||
|
||||
|
||||
func card_level() -> int:
|
||||
return 1 + int(salvage_total >= level2_salvage) + int(salvage_total >= level3_salvage)
|
||||
|
||||
|
||||
func hitstop(dur := -1.0) -> void:
|
||||
_hitstop_t = minf(maxf(_hitstop_t, dur if dur > 0.0 else hitstop_s), 0.15)
|
||||
|
||||
@@ -261,14 +283,17 @@ func _play_kinetic() -> void:
|
||||
var mech := _mech()
|
||||
if mech == null:
|
||||
return
|
||||
var lvl := card_level()
|
||||
var radius: float = kinetic_radius * level_radius_scale[lvl - 1]
|
||||
var impulse: float = kinetic_impulse * level_power_scale[lvl - 1]
|
||||
for e in get_tree().get_nodes_in_group("enemies"):
|
||||
var offset: Vector2 = e.global_position - mech.global_position
|
||||
var dist := offset.length()
|
||||
if dist > kinetic_radius:
|
||||
if dist > radius:
|
||||
continue
|
||||
var falloff := 1.0 - dist / kinetic_radius * 0.6
|
||||
e.hit(kinetic_damage, offset.normalized() * kinetic_impulse * falloff)
|
||||
_ring(mech.global_position, kinetic_radius, Color(0.75, 0.85, 0.95))
|
||||
var falloff := 1.0 - dist / radius * 0.6
|
||||
e.hit(kinetic_damage, offset.normalized() * impulse * falloff)
|
||||
_ring(mech.global_position, radius, Color(0.75, 0.85, 0.95))
|
||||
|
||||
|
||||
func _play_heat() -> void:
|
||||
@@ -278,10 +303,13 @@ func _play_heat() -> void:
|
||||
var mech := _mech()
|
||||
if mech == null:
|
||||
return
|
||||
var lvl := card_level()
|
||||
var radius: float = heat_radius * level_radius_scale[lvl - 1]
|
||||
var amount: float = heat_amount * level_power_scale[lvl - 1]
|
||||
for e in get_tree().get_nodes_in_group("enemies"):
|
||||
if e.global_position.distance_to(mech.global_position) <= heat_radius:
|
||||
e.add_heat(heat_amount)
|
||||
_ring(mech.global_position, heat_radius, Color(0.95, 0.55, 0.2))
|
||||
if e.global_position.distance_to(mech.global_position) <= radius:
|
||||
e.add_heat(amount)
|
||||
_ring(mech.global_position, radius, Color(0.95, 0.55, 0.2))
|
||||
|
||||
|
||||
func _ring(at: Vector2, radius: float, color: Color) -> void:
|
||||
|
||||
@@ -14,6 +14,7 @@ func _draw() -> void:
|
||||
var x0 := (640.0 - total) / 2.0
|
||||
var y0 := 320.0
|
||||
|
||||
var lvl: int = _hand.card_level()
|
||||
for i in _hand.cards.size():
|
||||
var card: Dictionary = _hand.cards[i]
|
||||
var rect := Rect2(x0 + i * (slot_w + gap), y0, slot_w, slot_h)
|
||||
@@ -26,6 +27,17 @@ func _draw() -> void:
|
||||
draw_rect(rect, hue)
|
||||
if _hand.hand_open:
|
||||
draw_rect(rect.grow(1.0), Color(1, 1, 1, 0.9 if affordable else 0.3), false, 1.0)
|
||||
# Card level: notches under the slot.
|
||||
for n in lvl:
|
||||
draw_rect(Rect2(rect.position.x + n * 8.0, rect.end.y + 3.0, 5.0, 3.0), Color(0.95, 0.8, 0.35))
|
||||
|
||||
# Salvage counter: scrap diamond + running total, right of the slots.
|
||||
var dx := x0 + total + 16.0
|
||||
var dy := y0 + 14.0
|
||||
var dia := PackedVector2Array([Vector2(dx, dy - 5), Vector2(dx + 4, dy), Vector2(dx, dy + 5), Vector2(dx - 4, dy)])
|
||||
draw_colored_polygon(dia, Color(0.95, 0.8, 0.35))
|
||||
draw_string(ThemeDB.fallback_font, Vector2(dx + 8.0, dy + 4.0), str(_hand.salvage_total),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color(0.85, 0.85, 0.9))
|
||||
|
||||
var pip := 5.0
|
||||
var pips_w: float = float(_hand.max_charges) * (pip + 3.0) - 3.0
|
||||
|
||||
@@ -28,10 +28,6 @@ func _physics_process(delta: float) -> void:
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body.is_in_group("enemies"):
|
||||
body.hit(damage, direction * knockback)
|
||||
# The build generates charges: every landed hit feeds the hand.
|
||||
var hand := get_tree().get_first_node_in_group("hand")
|
||||
if hand != null:
|
||||
hand.on_build_hit()
|
||||
queue_free()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
extends Area2D
|
||||
## Scrap drop — the positioning reward gradient (canon 2026-08-07:
|
||||
## kills drop scrap physically; collecting it gates in-run card
|
||||
## levels). Drifts to the mech inside the magnet radius, collected on
|
||||
## contact, fades out if ignored. Constructed in code, no scene.
|
||||
|
||||
var value := 1
|
||||
var magnet_radius := 70.0
|
||||
var drift_speed := 150.0
|
||||
var lifetime := 20.0
|
||||
|
||||
var _t := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("salvage")
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
monitorable = false
|
||||
var cs := CollisionShape2D.new()
|
||||
var sh := CircleShape2D.new()
|
||||
sh.radius = 6.0
|
||||
cs.shape = sh
|
||||
add_child(cs)
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_t += delta
|
||||
if _t >= lifetime:
|
||||
queue_free()
|
||||
return
|
||||
var mech := get_tree().get_first_node_in_group("mech") as Node2D
|
||||
if mech != null and global_position.distance_to(mech.global_position) <= magnet_radius:
|
||||
global_position = global_position.move_toward(mech.global_position, drift_speed * delta)
|
||||
if _t >= lifetime - 3.0:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body.is_in_group("mech"):
|
||||
var hand := get_tree().get_first_node_in_group("hand")
|
||||
if hand != null:
|
||||
hand.add_salvage(value)
|
||||
queue_free()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var a := 1.0 if _t < lifetime - 3.0 else clampf((lifetime - _t) / 3.0, 0.0, 1.0)
|
||||
var pts := PackedVector2Array([Vector2(0, -5), Vector2(4, 0), Vector2(0, 5), Vector2(-4, 0)])
|
||||
draw_colored_polygon(pts, Color(0.95, 0.8, 0.35, a))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs1xhn4u5821d
|
||||
+44
-17
@@ -89,32 +89,35 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void:
|
||||
_failures.append("no Hand node in arena")
|
||||
return
|
||||
|
||||
# Charges accrue from build hits alone (~4 hits per charge).
|
||||
if not await _until(func() -> bool: return hand.charges >= 2, 20.0, "build hits never accrued 2 charges"):
|
||||
# Charges accrue from kills alone (kills_per_charge, auto-fire only).
|
||||
if not await _until(func() -> bool: return hand.charges >= 2, 25.0, "kills never accrued 2 charges"):
|
||||
return
|
||||
|
||||
# A grunt must be near the mech before we freeze — cards are radial
|
||||
# around M03 and nothing walks while time is stopped.
|
||||
if not await _until(func() -> bool: return _nearest_grunt(mech) != null, 10.0, "no grunt approached within heat radius"):
|
||||
return
|
||||
|
||||
# Open the hand: time must freeze to the dilation knob (default 0).
|
||||
await _tap("hand_toggle")
|
||||
if not hand.hand_open:
|
||||
_failures.append("hand did not open on hand_toggle")
|
||||
# A grunt must be near the mech when we freeze — cards are radial
|
||||
# around M03 and nothing walks while time is stopped. The nearest
|
||||
# grunt can die to auto-fire during the toggle tap, so retry.
|
||||
var target: Node2D = null
|
||||
for attempt in 4:
|
||||
if not await _until(func() -> bool: return _nearest_grunt(mech) != null, 10.0, "no grunt approached within heat radius"):
|
||||
return
|
||||
await _tap("hand_toggle")
|
||||
if not hand.hand_open:
|
||||
_failures.append("hand did not open on hand_toggle")
|
||||
return
|
||||
# Pick the target while frozen — it can neither die nor wander.
|
||||
target = _nearest_grunt(mech)
|
||||
if target != null:
|
||||
break
|
||||
await _tap("hand_toggle")
|
||||
if target == null:
|
||||
_failures.append("grunt near mech vanished before freeze on 4 attempts")
|
||||
return
|
||||
var expect_pause: bool = (hand.meter_mode and hand.frac() >= hand.band_thaw) \
|
||||
or (not hand.meter_mode and hand.dilation_scale == 0.0)
|
||||
if expect_pause and not get_tree().paused:
|
||||
_failures.append("hand open in freeze state did not pause the world")
|
||||
|
||||
# Pick the target NOW — the world is frozen, so it can neither die
|
||||
# nor wander out of the heat radius before the casts land.
|
||||
var c0: int = hand.charges
|
||||
var target := _nearest_grunt(mech)
|
||||
if target == null:
|
||||
_failures.append("grunt near mech vanished before freeze")
|
||||
return
|
||||
|
||||
# Play HEAT twice: charges spent, heat painted past threshold.
|
||||
await _tap("card_2")
|
||||
@@ -140,9 +143,33 @@ 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_salvage(hand, mech)
|
||||
await _test_chemistry(arena, mech)
|
||||
|
||||
|
||||
func _test_salvage(hand: Node, mech: CharacterBody2D) -> void:
|
||||
# Kills drop scrap pickups.
|
||||
if not await _until(func() -> bool: return get_tree().get_first_node_in_group("salvage") != null, 15.0, "kills never dropped salvage"):
|
||||
return
|
||||
# Collection: teleport a drop onto the mech, expect pickup + count.
|
||||
var s: Area2D = get_tree().get_first_node_in_group("salvage")
|
||||
var s0: int = hand.salvage_total
|
||||
s.global_position = mech.global_position
|
||||
for i in 10:
|
||||
await get_tree().physics_frame
|
||||
if hand.salvage_total <= s0:
|
||||
_failures.append("salvage on the mech was not collected (total %d)" % hand.salvage_total)
|
||||
return
|
||||
if is_instance_valid(s):
|
||||
_failures.append("collected salvage node was not freed")
|
||||
# Level gating: cumulative scrap crosses thresholds, white-box.
|
||||
if hand.card_level() != 1 and hand.salvage_total < hand.level2_salvage:
|
||||
_failures.append("card level %d with only %d salvage" % [hand.card_level(), hand.salvage_total])
|
||||
hand.add_salvage(hand.level3_salvage)
|
||||
if hand.card_level() != 3:
|
||||
_failures.append("card level %d after crossing level3 threshold" % hand.card_level())
|
||||
|
||||
|
||||
func _test_meter(hand: Node) -> void:
|
||||
# Band ladder, white-box: set the meter, open, check the time state.
|
||||
# THAW band: open, unpaused, slow-mo strictly between 0 and 1.
|
||||
|
||||
Reference in New Issue
Block a user