diff --git a/ROADMAP.md b/ROADMAP.md index 38464f6..c2e861f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,10 +26,22 @@ The playable slice that falsified the design's three core bets: Post-falsifier addition, same milestone: kill-fed charges + salvage drops + salvage-gated card levels (canon 2026-08-07). -### M2 — (next: operator decision pending) +### M2 — run skeleton (BUILT 2026-08-07, playtest pending) -Candidates on the table: run skeleton (belt + draft + node map — -recommended), third tag (VOLT + 2 chemistry rows), tuning pass. +1. Belt — cards consumed on play, next slides in; drafting refills ✓ +2. Card draft node — 3 options + scrap decline (canon pick anatomy) ✓ +3. Node fork between rooms — CARD vs SCRAP cache ✓ +4. Room sequencing — spawn-budget rooms (4/run), clear → node → next ✓ +5. Run end — tally overlay + relaunch; death routes there too ✓ +6. Two new greybox cards (DASH, IGNITE) so drafting has texture ✓ + +Operator playtest verdict pending. Chain semantics (same-tag surge / +cross-tag chemistry trigger) deliberately NOT in M2 — M3 candidate. + +### M3 — (next: operator decision pending) + +Candidates: chain semantics + third tag (VOLT); tuning pass; base +stub (meta persistence between runs). ## Parking lot (post-greybox) diff --git a/persistent-memory.md b/persistent-memory.md index 2525ef6..5e62887 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -34,15 +34,17 @@ _As of 2026-08-07 (post-falsifier):_ - Salvage loop playtested and KEPT (operator, 2026-08-07: "interesting mechanic, let's keep it and move on"). Everything remains knobs; no tuning pass done yet. -- Greybox milestone (M1) complete and UNTAGGED — operator ruling - 2026-08-07: **v1.0 is reserved for the public release candidate**, - never internal milestones; internal milestones are 0.x. ROADMAP.md - restructured accordingly (M1 done, M2 = next-phase pick). -- Next-phase scope (M2) is the open operator decision — - recommendation on the table: belt + minimal node-map run skeleton - (bet 3's fun question resolves at the belt stage per canon). -- Advisor repo still has NO git remote (canon single-disk) — offered - twice, undecided. +- M1 (greybox falsifier) done, untagged — v1.0 reserved for the + public RC (operator ruling 2026-08-07; internal milestones are 0.x). +- **M2 (run skeleton) BUILT 2026-08-07, operator playtest pending**: + belt (consume-on-play, draft refills), draft node (3 + scrap + decline), CARD-vs-SCRAP fork between rooms, spawn-budget rooms + (4/run), tally + relaunch, DASH + IGNITE greybox cards. Chain + semantics deliberately deferred to M3. +- Advisor repo remote WIRED 2026-08-07: + git@gitea.phasefinal.com:vh/game-design-advisor.git (private, + created via tea CLI with the vh token), main pushed — canon is + backed up. ## Recent decisions diff --git a/project.godot b/project.godot index 888e808..bbc5e57 100644 --- a/project.godot +++ b/project.godot @@ -60,6 +60,26 @@ card_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) ] } +opt_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":49,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +opt_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":50,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +opt_3={ +"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":51,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +opt_4={ +"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":52,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [layer_names] diff --git a/scenes/arena.tscn b/scenes/arena.tscn index f325205..3b865dd 100644 --- a/scenes/arena.tscn +++ b/scenes/arena.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=9 format=3] +[gd_scene load_steps=11 format=3] [ext_resource type="Script" path="res://scripts/arena.gd" id="1"] [ext_resource type="PackedScene" path="res://scenes/mech.tscn" id="2"] @@ -6,6 +6,8 @@ [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"] +[ext_resource type="Script" path="res://scripts/interstitial.gd" id="7"] +[ext_resource type="Script" path="res://scripts/interstitial_hud.gd" id="8"] [sub_resource type="RectangleShape2D" id="wall_h"] size = Vector2(640, 16) @@ -89,3 +91,13 @@ anchor_right = 1.0 anchor_bottom = 1.0 mouse_filter = 2 script = ExtResource("5") + +[node name="Interstitial" type="CanvasLayer" parent="."] +layer = 3 +script = ExtResource("7") + +[node name="HUD" type="Control" parent="Interstitial"] +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +script = ExtResource("8") diff --git a/scripts/arena.gd b/scripts/arena.gd index c2916af..ebf0137 100644 --- a/scripts/arena.gd +++ b/scripts/arena.gd @@ -8,14 +8,87 @@ extends Node2D 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: diff --git a/scripts/hand.gd b/scripts/hand.gd index d07e0cc..e0f0fe4 100644 --- a/scripts/hand.gd +++ b/scripts/hand.gd @@ -72,32 +72,49 @@ extends CanvasLayer ## 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 +@export_group("KINETIC cards") @export var kinetic_radius := 160.0 @export var kinetic_impulse := 480.0 @export var kinetic_damage := 0.5 +@export var dash_impulse := 420.0 -@export_group("HEAT card") -@export var heat_cost := 1 +@export_group("HEAT cards") @export var heat_radius := 140.0 @export var heat_amount := 2.0 +@export var ignite_range := 200.0 +@export var ignite_heat := 3.5 + +@export_group("Belt") +## Starting belt as CARD_TYPES indices; cards are consumed on play, +## the next slides into the hand (canon: charges are the cost, the +## belt is the ammo). Drafting between rooms refills it. +@export var belt_start: Array[int] = [0, 1, 0, 1] +@export var belt_cap := 8 const FX_RING := preload("res://scripts/fx_ring.gd") +## The greybox card pool. Content prototypes inside ratified card-system +## canon — flag DASH/IGNITE for the canon card table when they earn it. +const CARD_TYPES: Array[Dictionary] = [ + {"name": "SHOCKWAVE", "glyph": "S", "hue": Color(0.75, 0.85, 0.95), "cost": 1, "method": "_play_kinetic"}, + {"name": "HEATWAVE", "glyph": "H", "hue": Color(0.95, 0.55, 0.2), "cost": 1, "method": "_play_heat"}, + {"name": "DASH", "glyph": "D", "hue": Color(0.85, 0.92, 1.0), "cost": 1, "method": "_play_dash"}, + {"name": "IGNITE", "glyph": "I", "hue": Color(1.0, 0.45, 0.15), "cost": 1, "method": "_play_ignite"}, +] + var charges := 0 var hand_open := false var meter := 0.0 var salvage_total := 0 +# The belt: CARD_TYPES indices; belt[0] and belt[1] are the hand +# (Q = slot 1, E = slot 2). Playing consumes the entry. +var belt: Array[int] = [] var _kill_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] = [] - func _ready() -> void: add_to_group("hand") @@ -106,10 +123,7 @@ func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS meter = meter_max _last_ms = Time.get_ticks_msec() - 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}, - ] + belt = belt_start.duplicate() func _exit_tree() -> void: @@ -134,19 +148,22 @@ func _process(_delta: float) -> void: else: meter = minf(meter + regen_rate * real_dt, meter_max) - if Input.is_action_just_pressed("hand_toggle"): + # While the interstitial owns the screen (and the pause), the hand + # does not fight it for input or time authority. + if not _interstitial_active(): + if Input.is_action_just_pressed("hand_toggle"): + if hand_open: + _set_open(false) + elif can_open(): + _set_open(true) if hand_open: - _set_open(false) - elif can_open(): - _set_open(true) - 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) - if meter_mode: - # Bands shift as the meter drains under an open hand. - _apply_time_state() + if Input.is_action_just_pressed("card_1"): + try_play(0) + if Input.is_action_just_pressed("card_2"): + try_play(1) + if meter_mode: + # Bands shift as the meter drains under an open hand. + _apply_time_state() # Release ramp: after the hand closes, time spools back up to full # speed on the wall clock instead of snapping. @@ -212,12 +229,30 @@ func frac() -> float: func try_play(slot: int) -> void: - var card := cards[slot] - var cost: int = card.cost_of.call() + if slot >= belt.size(): + return + var card := CARD_TYPES[belt[slot]] + var cost: int = card.cost if charges < cost: return charges -= cost - card.play.call() + belt.remove_at(slot) + call(card.method) + + +func belt_append(card_id: int) -> void: + if belt.size() < belt_cap: + belt.append(card_id) + + +func force_close() -> void: + if hand_open: + _set_open(false) + + +func _interstitial_active() -> bool: + var i := get_tree().get_first_node_in_group("interstitial") + return i != null and i.active func _set_open(open: bool) -> void: @@ -234,6 +269,8 @@ func _set_open(open: bool) -> void: func _apply_time_state() -> void: + if _interstitial_active(): + return var tree := get_tree() if not hand_open: tree.paused = false @@ -312,6 +349,44 @@ func _play_heat() -> void: _ring(mech.global_position, radius, Color(0.95, 0.55, 0.2)) +func _play_dash() -> void: + # Dash: impulse to M03 along the HELD MOVEMENT DIRECTION — no aim, + # and direction can be set while frozen (movement keys read during + # pause). Mobility verb; positioning stays the whole control surface. + var mech := _mech() + if mech == null: + return + var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down") + if dir == Vector2.ZERO: + dir = mech.velocity.normalized() if mech.velocity.length() > 1.0 else Vector2.UP + mech.impulse(dir * dash_impulse) + _ring(mech.global_position, 30.0, Color(0.85, 0.92, 1.0)) + + +func _play_ignite() -> void: + # Ignite: the nearest enemy in range takes a heat spike past the + # grunt threshold — a targeted pop, no aim (nearest = positioning). + var mech := _mech() + if mech == null: + return + var lvl := card_level() + var best: Node2D = null + var best_d := ignite_range * ignite_range + 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 + if best == null: + return + best.add_heat(ignite_heat * level_power_scale[lvl - 1]) + var ring: Node2D = FX_RING.new() + ring.position = best.global_position + ring.max_radius = 18.0 + ring.color = Color(1.0, 0.45, 0.15) + _mech().get_parent().add_child(ring) + + func _ring(at: Vector2, radius: float, color: Color) -> void: var ring: Node2D = FX_RING.new() ring.position = at diff --git a/scripts/hand_hud.gd b/scripts/hand_hud.gd index f97f767..59964f7 100644 --- a/scripts/hand_hud.gd +++ b/scripts/hand_hud.gd @@ -15,22 +15,35 @@ func _draw() -> void: var y0 := 320.0 var lvl: int = _hand.card_level() - for i in _hand.cards.size(): - var card: Dictionary = _hand.cards[i] + var font_slots := ThemeDB.fallback_font + for i in 2: var rect := Rect2(x0 + i * (slot_w + gap), y0, slot_w, slot_h) - var affordable: bool = _hand.charges >= card.cost_of.call() + if i >= _hand.belt.size(): + # Belt ran dry: empty slot frame. + draw_rect(rect, Color(0.2, 0.22, 0.25, 0.6)) + continue + var card: Dictionary = _hand.CARD_TYPES[_hand.belt[i]] + var affordable: bool = _hand.charges >= int(card.cost) 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) + draw_string(font_slots, Vector2(rect.position.x + 8.0, rect.position.y + 20.0), card.glyph, + HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color(0.1, 0.1, 0.12, 0.9)) 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)) + # Belt depth: upcoming cards as thin ticks left of the slots, in + # card hue — the ammo readout. + for i in range(2, _hand.belt.size()): + var card: Dictionary = _hand.CARD_TYPES[_hand.belt[i]] + draw_rect(Rect2(x0 - 12.0 - (i - 2) * 6.0, y0 + 8.0, 4.0, 16.0), Color(card.hue.r, card.hue.g, card.hue.b, 0.7)) + # Salvage counter: scrap diamond + running total, right of the slots. var dx := x0 + total + 16.0 var dy := y0 + 14.0 diff --git a/scripts/interstitial.gd b/scripts/interstitial.gd new file mode 100644 index 0000000..f280fc9 --- /dev/null +++ b/scripts/interstitial.gd @@ -0,0 +1,88 @@ +extends CanvasLayer +## Between-rooms layer: node fork (CARD vs SCRAP), card draft (3 +## options + scrap decline — canon pick anatomy, compressed), and the +## run tally. Pauses the world while active; runs ALWAYS so it can +## read input. Number keys 1-4 select. Greybox dev UI — draw calls +## and letters, no art. + +signal fork_chosen(kind: String) +signal draft_chosen(card_id: int) +signal restart_requested + +const MODE_NONE := "" +const MODE_FORK := "fork" +const MODE_DRAFT := "draft" +const MODE_TALLY := "tally" + +@export var fork_scrap_reward := 8 +@export var draft_decline_scrap := 4 + +var mode := MODE_NONE +var active := false +var draft_options: Array[int] = [] +var tally_stats := {} +var tally_failed := false + + +func _ready() -> void: + add_to_group("interstitial") + process_mode = Node.PROCESS_MODE_ALWAYS + layer = 3 + visible = false + + +func open_fork() -> void: + _open(MODE_FORK) + + +func open_draft(options: Array[int]) -> void: + draft_options = options + _open(MODE_DRAFT) + + +func open_tally(stats: Dictionary, failed: bool) -> void: + tally_stats = stats + tally_failed = failed + _open(MODE_TALLY) + + +func close() -> void: + mode = MODE_NONE + active = false + visible = false + get_tree().paused = false + + +func _open(m: String) -> void: + mode = m + active = true + visible = true + get_tree().paused = true + $HUD.queue_redraw() + + +func _process(_delta: float) -> void: + if not active: + return + $HUD.queue_redraw() + match mode: + MODE_FORK: + if Input.is_action_just_pressed("opt_1"): + close() + fork_chosen.emit("card") + elif Input.is_action_just_pressed("opt_2"): + close() + fork_chosen.emit("scrap") + MODE_DRAFT: + for i in draft_options.size(): + if Input.is_action_just_pressed("opt_%d" % (i + 1)): + close() + draft_chosen.emit(draft_options[i]) + return + if Input.is_action_just_pressed("opt_4"): + close() + draft_chosen.emit(-1) + MODE_TALLY: + if Input.is_action_just_pressed("opt_1"): + close() + restart_requested.emit() diff --git a/scripts/interstitial.gd.uid b/scripts/interstitial.gd.uid new file mode 100644 index 0000000..6917ac7 --- /dev/null +++ b/scripts/interstitial.gd.uid @@ -0,0 +1 @@ +uid://jel57j8g5hj4 diff --git a/scripts/interstitial_hud.gd b/scripts/interstitial_hud.gd new file mode 100644 index 0000000..15c2758 --- /dev/null +++ b/scripts/interstitial_hud.gd @@ -0,0 +1,53 @@ +extends Control +## Draw layer for the interstitial (fork / draft / tally). Reads the +## parent CanvasLayer's state. Greybox dev UI. + +@onready var _layer: CanvasLayer = get_parent() + +const KINETIC_HUE := Color(0.75, 0.85, 0.95) +const HEAT_HUE := Color(0.95, 0.55, 0.2) +const SCRAP_HUE := Color(0.95, 0.8, 0.35) + + +func _draw() -> void: + if not _layer.active: + return + var font := ThemeDB.fallback_font + draw_rect(Rect2(0, 0, 640, 360), Color(0, 0, 0, 0.55)) + match _layer.mode: + "fork": + _tile(Rect2(230, 140, 60, 56), Color(0.35, 0.4, 0.45), "1") + draw_rect(Rect2(248, 152, 24, 32), KINETIC_HUE.lerp(HEAT_HUE, 0.5)) + _tile(Rect2(350, 140, 60, 56), Color(0.35, 0.4, 0.45), "2") + _diamond(Vector2(380, 166)) + draw_string(font, Vector2(372, 186), "+%d" % _layer.fork_scrap_reward, HORIZONTAL_ALIGNMENT_LEFT, -1, 9, Color(0.9, 0.9, 0.9)) + "draft": + var hand := get_tree().get_first_node_in_group("hand") + for i in _layer.draft_options.size(): + var card: Dictionary = hand.CARD_TYPES[_layer.draft_options[i]] + var r := Rect2(200 + i * 76, 140, 56, 64) + _tile(r, Color(0.3, 0.33, 0.38), str(i + 1)) + draw_rect(Rect2(r.position.x + 16, r.position.y + 10, 24, 32), card.hue) + draw_string(font, Vector2(r.position.x + 22, r.position.y + 30), card.glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color(0.1, 0.1, 0.12)) + _tile(Rect2(200 + 3 * 76, 148, 56, 48), Color(0.3, 0.33, 0.38), "4") + _diamond(Vector2(200 + 3 * 76 + 24, 168)) + draw_string(font, Vector2(200 + 3 * 76 + 12, 186), "+%d" % _layer.draft_decline_scrap, HORIZONTAL_ALIGNMENT_LEFT, -1, 9, Color(0.9, 0.9, 0.9)) + "tally": + var col := Color(0.75, 0.3, 0.25) if _layer.tally_failed else Color(0.45, 0.7, 0.45) + draw_rect(Rect2(200, 110, 240, 8), col) + var y := 150.0 + for key in _layer.tally_stats: + draw_string(font, Vector2(250, y), "%s %s" % [key, str(_layer.tally_stats[key])], HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.9, 0.9, 0.92)) + y += 20.0 + draw_string(font, Vector2(250, y + 12), "1 = relaunch", HORIZONTAL_ALIGNMENT_LEFT, -1, 9, Color(0.6, 0.65, 0.7)) + + +func _tile(r: Rect2, c: Color, key_hint: String) -> void: + draw_rect(r, c) + draw_rect(r.grow(1.0), Color(0.9, 0.9, 0.95, 0.5), false, 1.0) + draw_string(ThemeDB.fallback_font, Vector2(r.position.x + 3, r.end.y - 4), key_hint, HORIZONTAL_ALIGNMENT_LEFT, -1, 8, Color(0.95, 0.95, 0.95, 0.8)) + + +func _diamond(at: Vector2) -> void: + var pts := PackedVector2Array([at + Vector2(0, -6), at + Vector2(5, 0), at + Vector2(0, 6), at + Vector2(-5, 0)]) + draw_colored_polygon(pts, SCRAP_HUE) diff --git a/scripts/interstitial_hud.gd.uid b/scripts/interstitial_hud.gd.uid new file mode 100644 index 0000000..cbcef26 --- /dev/null +++ b/scripts/interstitial_hud.gd.uid @@ -0,0 +1 @@ +uid://bdf8tosbv1a2r diff --git a/scripts/mech.gd b/scripts/mech.gd index de71d56..fcf6d07 100644 --- a/scripts/mech.gd +++ b/scripts/mech.gd @@ -4,13 +4,16 @@ extends CharacterBody2D ## combos) — movement input is the entire control surface. signal hull_changed(hull: float, hull_max: float) +signal destroyed @export var move_speed := 160.0 @export var accel := 900.0 @export var decel := 1200.0 @export var hull_max := 100.0 +@export var dash_decay := 4.0 var hull: float +var _dash := Vector2.ZERO func _ready() -> void: @@ -21,15 +24,24 @@ func _ready() -> void: func _physics_process(delta: float) -> void: var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down") var rate := accel if dir != Vector2.ZERO else decel - velocity = velocity.move_toward(dir * move_speed, rate * delta) + _dash = _dash.lerp(Vector2.ZERO, 1.0 - exp(-dash_decay * delta)) + velocity = velocity.move_toward(dir * move_speed, rate * delta) + _dash move_and_slide() + velocity -= _dash + + +func impulse(v: Vector2) -> void: + _dash += v func take_damage(amount: float) -> void: + if hull <= 0.0: + return hull = maxf(hull - amount, 0.0) hull_changed.emit(hull, hull_max) if hull <= 0.0: - get_tree().reload_current_scene() + # The run manager decides what death means (tally + relaunch). + destroyed.emit() func _draw() -> void: diff --git a/scripts/spawner.gd b/scripts/spawner.gd index 11fbc17..31f603b 100644 --- a/scripts/spawner.gd +++ b/scripts/spawner.gd @@ -16,6 +16,19 @@ const BURSTER := preload("res://scenes/burster.tscn") var _timer := 0.0 var _spawned := 0 +# Room budget: > 0 caps total spawns this room; 0 = endless trickle. +var room_total := 0 +var room_spawned := 0 + + +func begin_room(total: int, grace := 1.5) -> void: + room_total = total + room_spawned = 0 + _timer = grace + + +func room_exhausted() -> bool: + return room_total > 0 and room_spawned >= room_total func _physics_process(delta: float) -> void: @@ -23,8 +36,12 @@ func _physics_process(delta: float) -> void: if _timer > 0.0: return _timer = spawn_interval + if room_exhausted(): + return if get_tree().get_nodes_in_group("enemies").size() < target_count: _spawn() + if room_total > 0: + room_spawned += 1 func _spawn() -> void: diff --git a/tests/smoke.gd b/tests/smoke.gd index fd78a1c..826e45f 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -31,6 +31,9 @@ func _on_global_timeout() -> void: func _run() -> void: var arena := ARENA.instantiate() + # Mechanic phases need the old endless trickle; the run-structure + # phase re-enables rooms with tiny budgets at the end. + arena.rooms_enabled = false add_child(arena) await get_tree().physics_frame await get_tree().physics_frame @@ -117,6 +120,9 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void: if expect_pause and not get_tree().paused: _failures.append("hand open in freeze state did not pause the world") + # Belt white-box: slot 2 must serve HEAT twice in a row (plays + # consume belt entries, the next slides in). + hand.belt.assign([0, 1, 1, 0]) var c0: int = hand.charges # Play HEAT twice: charges spent, heat painted past threshold. @@ -145,6 +151,47 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void: await _test_meter(hand) await _test_salvage(hand, mech) await _test_chemistry(arena, mech) + await _test_run(arena, hand) + + +func _test_run(arena: Node, hand: Node) -> void: + # Run structure with tiny budgets: clear -> fork -> draft grows the + # belt -> room advances -> final clear -> tally (left open; the + # relaunch path would reload the smoke scene). + arena.rooms_per_run = 2 + arena.room = 1 + arena.room_budget_base = 2 + arena.room_budget_per_room = 0 + arena.rooms_enabled = true + arena._begin_room() + for e in get_tree().get_nodes_in_group("enemies"): + e.hit(9999.0, Vector2.ZERO) + var inter: CanvasLayer = arena.get_node("Interstitial") + if not await _until(func() -> bool: return inter.active, 30.0, "room clear never opened the interstitial"): + return + if inter.mode != "fork": + _failures.append("expected fork after non-final room, got '%s'" % inter.mode) + return + if not get_tree().paused: + _failures.append("interstitial did not pause the world") + await _tap("opt_1") + if not inter.active or inter.mode != "draft": + _failures.append("fork CARD choice did not open the draft (mode '%s')" % inter.mode) + return + var b0: int = hand.belt.size() + await _tap("opt_1") + if hand.belt.size() != b0 + 1: + _failures.append("draft pick did not grow the belt (%d -> %d)" % [b0, hand.belt.size()]) + if inter.active or get_tree().paused: + _failures.append("interstitial did not close after the draft pick") + return + if arena.room != 2: + _failures.append("room did not advance after draft (room %d)" % arena.room) + return + if not await _until(func() -> bool: return inter.active, 30.0, "final room never reached the tally"): + return + if inter.mode != "tally": + _failures.append("expected tally after final room, got '%s'" % inter.mode) func _test_salvage(hand: Node, mech: CharacterBody2D) -> void: @@ -290,13 +337,15 @@ func _tap(action: String) -> void: await get_tree().process_frame +## Awaits process_frame, not physics_frame: physics stops under pause +## (hand freeze, interstitial) and a physics await would deadlock. func _until(pred: Callable, timeout: float, what: String) -> bool: var t := 0.0 while t < timeout: if pred.call(): return true - await get_tree().physics_frame - t += get_physics_process_delta_time() + await get_tree().process_frame + t += 1.0 / 60.0 _failures.append("timeout: " + what) return false