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.
This commit is contained in:
+15
-3
@@ -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)
|
||||
|
||||
|
||||
+11
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
+13
-1
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
+101
-26
@@ -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
|
||||
|
||||
+16
-3
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://jel57j8g5hj4
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdf8tosbv1a2r
|
||||
+14
-2
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+51
-2
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user