feat: card layer — 2-slot hand, build-generated charges, card-time freeze/dilate

Hand (CanvasLayer, PROCESS_MODE_ALWAYS): Space toggles the hand, Q/E
play slot 1/2, charges accrue from auto-fire hits (4 hits/charge, cap
5). KINETIC card: radial shockwave shove + light damage. HEAT card:
paints heat status in a radius; heat accumulates, glows, detonates at
threshold (damage only, no knockback — tag purity). Freeze/dilate is
an exposed knob: [ and ] step dilation 0.0-0.3 live.

Freeze uses SceneTree.paused, not Engine.time_scale = 0: probed on
4.7 headless — time_scale 0 still steps physics with delta 0, so
delta-independent logic (heat thresholds, contact callbacks) kept
firing while nominally frozen. Dilation > 0 uses time_scale slow-mo.
Hand._exit_tree restores pause + time_scale so hull-zero reloads can
never strand a frozen world.

Smoke gate extended: charge accrual from build hits, pause-on-open at
knob 0, HEAT x2 spend + threshold, detonation on release. 3/3 green
on nh3-dev headless 4.7.1.
This commit is contained in:
2026-08-06 22:42:46 -07:00
parent 380088c694
commit c340b7bc48
8 changed files with 405 additions and 9 deletions
+15
View File
@@ -45,6 +45,21 @@ move_down={
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
hand_toggle={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
card_1={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
card_2={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[layer_names]
+19 -1
View File
@@ -1,8 +1,10 @@
[gd_scene load_steps=6 format=3]
[gd_scene load_steps=8 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"]
[sub_resource type="RectangleShape2D" id="wall_h"]
size = Vector2(640, 16)
@@ -66,3 +68,19 @@ offset_top = 9.0
offset_right = 127.0
offset_bottom = 13.0
color = Color(0.55, 0.8, 0.55, 1)
[node name="Hand" type="CanvasLayer" parent="."]
layer = 2
script = ExtResource("4")
[node name="Dim" type="ColorRect" parent="Hand"]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0, 0, 0, 0.35)
[node name="HUD" type="Control" parent="Hand"]
anchor_right = 1.0
anchor_bottom = 1.0
mouse_filter = 2
script = ExtResource("5")
+23
View File
@@ -0,0 +1,23 @@
extends Node2D
## One-shot expanding ring, greybox stand-in for any radial event
## (card blasts, heat detonations). Runs on scaled time, so a ring
## spawned during freeze blooms on release — which is the rhythm.
var max_radius := 56.0
var color := Color(1.0, 0.6, 0.2)
var duration := 0.18
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 c := Color(color.r, color.g, color.b, 1.0 - k)
draw_arc(Vector2.ZERO, maxf(max_radius * k, 1.0), 0.0, TAU, 48, c, 3.0)
+61 -5
View File
@@ -1,8 +1,13 @@
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. Knockback state lives here so KINETIC (and later
## chemistry) has something to shove.
## tag behavior of its own; heat is a status OTHERS put on it.
##
## 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).
signal died
@@ -12,9 +17,18 @@ signal died
@export var contact_interval := 0.5
@export var knockback_decay := 5.0
@export_group("Heat status")
@export var heat_threshold := 3.0
@export var detonation_damage := 1.5
@export var detonation_radius := 56.0
const FX_RING := preload("res://scripts/fx_ring.gd")
var heat := 0.0
var _knockback := Vector2.ZERO
var _contact_cd := 0.0
var _flash := 0.0
var _dead := false
var _mech: Node2D
@@ -24,6 +38,10 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
if heat >= heat_threshold:
_detonate()
return
_contact_cd -= delta
var seek := Vector2.ZERO
if is_instance_valid(_mech):
@@ -44,15 +62,53 @@ func _physics_process(delta: float) -> void:
func hit(damage: float, kb: Vector2) -> void:
if _dead:
return
hp -= damage
_knockback += kb
_flash = 0.12
queue_redraw()
if hp <= 0.0:
died.emit()
queue_free()
_die()
func add_heat(amount: float) -> void:
if _dead:
return
heat += amount
queue_redraw()
func _detonate() -> void:
if _dead:
return
for e in get_tree().get_nodes_in_group("enemies"):
if e == self:
continue
if global_position.distance_to(e.global_position) <= detonation_radius:
e.hit(detonation_damage, Vector2.ZERO)
var ring: Node2D = FX_RING.new()
ring.position = global_position
ring.max_radius = detonation_radius
get_parent().add_child(ring)
_die()
func _die() -> void:
if _dead:
return
_dead = true
died.emit()
queue_free()
func _draw() -> void:
var body := Color(0.95, 0.95, 0.95) if _flash > 0.0 else Color(0.45, 0.47, 0.4)
var body := Color(0.45, 0.47, 0.4)
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)
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)
+153
View File
@@ -0,0 +1,153 @@
extends CanvasLayer
## 2-slot hand + universal charge pool + card-time dilation.
##
## Canon mapping (CONCEPT.md § loop stack / § tag grammar): the build
## generates charges (auto-fire hits feed the pool — tightest
## build<->hand coupling), opening the hand dilates or freezes time
## (flow -> freeze -> think -> release), charges are one universal
## currency. The freeze/dilate scale is THE exposed playtest knob:
## [ and ] step it live, 0.0 = full freeze.
##
## Greybox shortcut, deliberate: the two cards are entries in CARDS
## with a method each, not card resources — structured cards arrive
## with the belt (post-greybox, parked).
@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 max_charges := 5
@export_group("KINETIC card")
@export var kinetic_cost := 1
@export var kinetic_radius := 160.0
@export var kinetic_impulse := 480.0
@export var kinetic_damage := 0.5
@export_group("HEAT card")
@export var heat_cost := 1
@export var heat_radius := 140.0
@export var heat_amount := 2.0
const FX_RING := preload("res://scripts/fx_ring.gd")
var charges := 0
var hand_open := false
var _hit_progress := 0
# Slot order is the hand: Q = slot 1, E = slot 2.
var cards: Array[Dictionary] = []
func _ready() -> void:
add_to_group("hand")
# The hand must keep processing (and receiving input) while the
# world is paused for a full freeze.
process_mode = Node.PROCESS_MODE_ALWAYS
cards = [
{"name": "KINETIC", "hue": Color(0.75, 0.85, 0.95), "cost_of": func() -> int: return kinetic_cost, "play": _play_kinetic},
{"name": "HEAT", "hue": Color(0.95, 0.55, 0.2), "cost_of": func() -> int: return heat_cost, "play": _play_heat},
]
func _exit_tree() -> void:
# Scene reload while frozen must never leave the world stopped.
Engine.time_scale = 1.0
get_tree().paused = false
func _process(_delta: float) -> void:
if Input.is_action_just_pressed("hand_toggle"):
_set_open(not hand_open)
if hand_open:
if Input.is_action_just_pressed("card_1"):
try_play(0)
if Input.is_action_just_pressed("card_2"):
try_play(1)
$HUD.queue_redraw()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
if event.physical_keycode == KEY_BRACKETLEFT:
dilation_scale = maxf(0.0, dilation_scale - dilation_step)
_apply_time_scale()
elif event.physical_keycode == KEY_BRACKETRIGHT:
dilation_scale = minf(dilation_max, dilation_scale + dilation_step)
_apply_time_scale()
func on_build_hit() -> void:
_hit_progress += 1
if _hit_progress >= hits_per_charge:
_hit_progress = 0
charges = mini(charges + 1, max_charges)
func try_play(slot: int) -> void:
var card := cards[slot]
var cost: int = card.cost_of.call()
if charges < cost:
return
charges -= cost
card.play.call()
func _set_open(open: bool) -> void:
hand_open = open
$Dim.visible = open
_apply_time_scale()
func _apply_time_scale() -> void:
# Knob 0 = a REAL pause: Engine.time_scale = 0 still steps physics
# with delta 0 on 4.7 (probed headless — delta-independent logic
# like heat thresholds kept firing "frozen"). SceneTree.paused
# stops physics outright; time_scale covers dilation > 0.
if hand_open and dilation_scale == 0.0:
get_tree().paused = true
Engine.time_scale = 1.0
else:
get_tree().paused = false
Engine.time_scale = dilation_scale if hand_open else 1.0
func _mech() -> Node2D:
return get_tree().get_first_node_in_group("mech") as Node2D
func _play_kinetic() -> void:
# Shockwave: radial shove from M03, falling off to the rim. No aim —
# where you're standing IS the targeting (positioning <-> build bet).
var mech := _mech()
if mech == null:
return
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:
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))
func _play_heat() -> void:
# Heat wave: paints heat on everything near M03. Heat is a status —
# it detonates at threshold (grunt-side), and next slice's
# KINETIC+HEAT chemistry row reads the same state.
var mech := _mech()
if mech == null:
return
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))
func _ring(at: Vector2, radius: float, color: Color) -> void:
var ring: Node2D = FX_RING.new()
ring.position = at
ring.max_radius = radius
ring.color = color
_mech().get_parent().add_child(ring)
+40
View File
@@ -0,0 +1,40 @@
extends Control
## Greybox hand HUD: two card slots (bottom center), charge pips above
## them, dilation knob readout (dev instrumentation, not game UI).
## Iconic, not textual — slots are tag-hue blocks, no card text.
@onready var _hand: CanvasLayer = get_parent()
func _draw() -> void:
var slot_w := 24.0
var slot_h := 32.0
var gap := 6.0
var total := slot_w * 2 + gap
var x0 := (640.0 - total) / 2.0
var y0 := 320.0
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)
var affordable: bool = _hand.charges >= card.cost_of.call()
var hue: Color = card.hue
if not affordable:
hue = Color(hue.r, hue.g, hue.b, 0.25)
elif not _hand.hand_open:
hue = Color(hue.r, hue.g, hue.b, 0.6)
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)
var pip := 5.0
var pips_w: float = float(_hand.max_charges) * (pip + 3.0) - 3.0
var px0: float = (640.0 - pips_w) / 2.0
for i in int(_hand.max_charges):
var filled: bool = i < int(_hand.charges)
var c := Color(0.9, 0.9, 0.95) if filled else Color(0.9, 0.9, 0.95, 0.18)
draw_rect(Rect2(px0 + i * (pip + 3.0), y0 - 10.0, pip, pip), c)
var font := ThemeDB.fallback_font
draw_string(font, Vector2(560, 352), "dt x%.2f" % _hand.dilation_scale,
HORIZONTAL_ALIGNMENT_LEFT, -1, 8, Color(0.6, 0.65, 0.7, 0.7))
+4
View File
@@ -28,6 +28,10 @@ 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()
+90 -3
View File
@@ -2,8 +2,14 @@ extends Node
## Headless smoke test for the tracer bullet. Run:
## godot --headless --path . res://tests/smoke.tscn
## Asserts: arena boots, piloting moves the mech, grunts spawn, the
## weapon auto-fires with no input, a grunt dies, knockback displaces.
## weapon auto-fires with no input, a grunt dies, knockback displaces,
## build hits accrue charges, opening the hand freezes time, the HEAT
## card paints heat, and stacked heat detonates on release.
## Exit 0 = pass, 1 = fail.
##
## Await discipline: physics_frame only while time flows — with the
## hand open at dilation 0.0 physics never steps, so frozen phases
## await process_frame instead.
const ARENA := preload("res://scenes/arena.tscn")
@@ -12,14 +18,14 @@ var _done := false
func _ready() -> void:
get_tree().create_timer(25.0).timeout.connect(_on_global_timeout)
get_tree().create_timer(60.0).timeout.connect(_on_global_timeout)
await _run()
_finish()
func _on_global_timeout() -> void:
if not _done:
_failures.append("global 25s timeout — test hung")
_failures.append("global 60s timeout — test hung")
_finish()
@@ -33,6 +39,10 @@ func _run() -> void:
if mech == null:
_failures.append("no Mech node in arena")
return
# God-mode the mech: the long sim phases would otherwise drain hull
# to zero mid-test and reload the scene under our assertions.
mech.hull_max = 1.0e9
mech.hull = 1.0e9
# Piloting: press right, mech must move right.
var x0 := mech.global_position.x
@@ -70,6 +80,83 @@ func _run() -> void:
elif g.global_position.x <= gx + 2.0:
_failures.append("knockback: grunt did not displace (x %.1f -> %.1f)" % [gx, g.global_position.x])
await _test_hand(arena, mech)
func _test_hand(arena: Node, mech: CharacterBody2D) -> void:
var hand := arena.get_node_or_null("Hand")
if hand == null:
_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"):
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")
return
if hand.dilation_scale == 0.0:
if not get_tree().paused:
_failures.append("hand open at knob 0 did not pause the world")
elif absf(Engine.time_scale - hand.dilation_scale) > 0.001:
_failures.append("time_scale %.3f != dilation knob %.3f with hand open" % [Engine.time_scale, hand.dilation_scale])
# 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")
await _tap("card_2")
if hand.charges != c0 - 2:
_failures.append("HEAT x2 spent %d charges, expected 2" % (c0 - hand.charges))
if target.heat < target.heat_threshold:
_failures.append("HEAT x2 left target below threshold (heat %.1f)" % target.heat)
# Release: time resumes, the over-threshold grunt detonates.
await _tap("hand_toggle")
if hand.hand_open or get_tree().paused or Engine.time_scale != 1.0:
_failures.append("hand did not close / world did not resume")
return
for i in 10:
await get_tree().physics_frame
if is_instance_valid(target):
_failures.append("heated grunt failed to detonate after release (heat %.1f)" % target.heat)
func _nearest_grunt(mech: CharacterBody2D) -> Node2D:
var best: Node2D = null
var best_d := 120.0 * 120.0
for e in get_tree().get_nodes_in_group("enemies"):
var d: float = mech.global_position.distance_squared_to(e.global_position)
if d < best_d:
best_d = d
best = e
return best
## Synthetic key tap via the action layer, safe under freeze: presses,
## lets two process frames run so is_action_just_pressed is seen, then
## releases and lets one more run.
func _tap(action: String) -> void:
Input.action_press(action)
await get_tree().process_frame
await get_tree().process_frame
Input.action_release(action)
await get_tree().process_frame
func _until(pred: Callable, timeout: float, what: String) -> bool:
var t := 0.0