Files
vh fc4fc59a2b feat: M2 run skeleton — belt, card draft, node fork, room sequencing, run tally
Belt (canon: charges are the cost, the belt is the ammo): cards are
CARD_TYPES indices consumed on play, next slides into the 2-slot hand;
belt cap 8; HUD shows hand glyphs + upcoming-card ticks. Card pool
grows to 4: SHOCKWAVE, HEATWAVE, DASH (impulse along held movement
direction — aimable while frozen, no aim input), IGNITE (nearest-enemy
heat spike past threshold). Rooms: spawn-budget waves (base 8 +4/room,
4 rooms/run); clear -> interstitial fork (CARD vs SCRAP +8) -> CARD
opens a 3-option draft + scrap decline (+4) -> next room; final clear
or mech destruction -> tally overlay (rooms/kills/scrap/chem) with
relaunch on 1. Interstitial owns the pause while active; the hand
yields input and time authority to it. Mech death now signals the run
manager instead of hard-reloading.

Chain semantics (same-tag surge / cross-tag chemistry trigger)
deliberately deferred to M3.

Smoke gate: belt consumption white-boxed in the card phase, _until
moved to process_frame (physics awaits deadlock under pause), run
phase covers clear->fork->draft->belt growth->room advance->tally.
3/3 green on nh3-dev headless 4.7.1.
2026-08-07 20:30:43 -07:00

139 lines
3.8 KiB
GDScript

extends Node2D
## One greybox arena, one screen (640x360), fixed camera. Draws its own
## 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")
const SALVAGE := preload("res://scripts/salvage.gd")
## Run structure: rooms with a spawn budget; clear a room -> node fork
## (card draft vs scrap) -> next room; final room or death -> tally.
@export var rooms_enabled := true
@export var rooms_per_run := 4
@export var room_budget_base := 8
@export var room_budget_per_room := 4
var kills: int = 0
var chem_procs: int = 0
var room: int = 1
var rooms_cleared: int = 0
func _ready() -> void:
add_to_group("arena")
$Spawner.grunt_spawned.connect(_on_grunt_spawned)
$Mech.hull_changed.connect(_on_hull_changed)
$Mech.destroyed.connect(_on_mech_destroyed)
$Interstitial.fork_chosen.connect(_on_fork_chosen)
$Interstitial.draft_chosen.connect(_on_draft_chosen)
$Interstitial.restart_requested.connect(func() -> void: get_tree().reload_current_scene())
if rooms_enabled:
_begin_room()
func _physics_process(_delta: float) -> void:
if not rooms_enabled or $Interstitial.active:
return
if $Spawner.room_exhausted() and get_tree().get_nodes_in_group("enemies").is_empty():
_room_cleared()
func _begin_room() -> void:
$Spawner.begin_room(room_budget_base + (room - 1) * room_budget_per_room)
func _room_cleared() -> void:
rooms_cleared += 1
$Hand.force_close()
if room >= rooms_per_run:
$Interstitial.open_tally(_stats(), false)
else:
$Interstitial.open_fork()
func _on_fork_chosen(kind: String) -> void:
if kind == "scrap":
$Hand.add_salvage($Interstitial.fork_scrap_reward)
_advance()
else:
var opts: Array[int] = []
for i in 3:
opts.append(randi() % $Hand.CARD_TYPES.size())
$Interstitial.open_draft(opts)
func _on_draft_chosen(card_id: int) -> void:
if card_id >= 0:
$Hand.belt_append(card_id)
else:
$Hand.add_salvage($Interstitial.draft_decline_scrap)
_advance()
func _advance() -> void:
room += 1
_begin_room()
func _on_mech_destroyed() -> void:
$Hand.force_close()
$Interstitial.open_tally(_stats(), true)
func _stats() -> Dictionary:
return {
"rooms": rooms_cleared,
"kills": kills,
"scrap": $Hand.salvage_total,
"chem": chem_procs,
}
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: _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:
$HUD/HullFill.size.x = 118.0 * (hull / hull_max)
func _draw() -> void:
draw_rect(Rect2(0, 0, 640, 360), Color(0.1, 0.11, 0.13))
for x in range(0, 641, 32):
draw_line(Vector2(x, 0), Vector2(x, 360), Color(0.13, 0.145, 0.17))
for y in range(0, 361, 32):
draw_line(Vector2(0, y), Vector2(640, y), Color(0.13, 0.145, 0.17))
var wall := Color(0.3, 0.32, 0.36)
draw_rect(Rect2(0, 0, 640, 16), wall)
draw_rect(Rect2(0, 344, 640, 16), wall)
draw_rect(Rect2(0, 0, 16, 360), wall)
draw_rect(Rect2(624, 0, 16, 360), wall)