From c1eca9e1a069e34d4d2961c48d4341f564ee4542 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 16 Aug 2026 15:11:36 +1000 Subject: [PATCH] hp_bands wardrobe mode: states as mini health bars + segmented HUD Each wardrobe state owns an equal hp band; emptying a band breaks the fighter into the next look (white flash). HUD draws one colored segment per band. --demo=bands + --shot-ticks for multi-shot verification. Vesper (geared/base/torn) switched to hp_bands as the pilot. Co-Authored-By: Claude Fable 5 --- characters/_spec/CHARACTER_SPEC.md | 17 ++++ characters/vesper/manifest.json | 2 +- engine/core/character_data.gd | 2 + engine/core/fighter.gd | 31 +++++-- engine/core/match_engine.gd | 137 ++++++++++++++++++++++------- 5 files changed, 150 insertions(+), 39 deletions(-) diff --git a/characters/_spec/CHARACTER_SPEC.md b/characters/_spec/CHARACTER_SPEC.md index c3b57729..95321275 100644 --- a/characters/_spec/CHARACTER_SPEC.md +++ b/characters/_spec/CHARACTER_SPEC.md @@ -66,6 +66,23 @@ shredding) or `--attach ...` (bone-parented gear, see `pipeline/render_geared.sh` for calibrated specs). Per-character: a fighter with `["base","torn"]` simply has no gear stage. +### hp_bands mode — punch through the layers + +```json +"wardrobe": { "states": ["geared", "base", "torn"], "hp_bands": true } +``` + +With `hp_bands`, the ordered states split max health into **equal hp bands** +and the active state follows the band hp is in. Each state is a mini health +bar: the HUD draws one colored segment per band, and emptying a segment +breaks the fighter through into the next look (white sprite flash on the +break). Forward-only — a passed band is never re-entered. `tears` and +`tear_below_hp` are ignored in this mode. States don't need full variant +coverage: missing `frames@/` dirs fall back to base per-move, so a +style tier that only covers idle/walk/hit/jab/straight/kick still reads. +The states can be *any* ordered looks, not just wardrobe — e.g. render +styles (`["base", "sketch", "wireframe"]`) made with the restyle pipeline. + ## moves//data.json ```json diff --git a/characters/vesper/manifest.json b/characters/vesper/manifest.json index 7df711d7..13774750 100644 --- a/characters/vesper/manifest.json +++ b/characters/vesper/manifest.json @@ -13,7 +13,7 @@ "base", "torn" ], - "tear_below_hp": 0.35 + "hp_bands": true }, "normals": { "P": "jab", diff --git a/engine/core/character_data.gd b/engine/core/character_data.gd index a141fbb0..52a62706 100644 --- a/engine/core/character_data.gd +++ b/engine/core/character_data.gd @@ -13,6 +13,7 @@ var color := Color.WHITE var sprite_scale := 1.0 # draw scale; also scales all box coords var wardrobe_states: Array = [] # ordered, e.g. ["base", "torn"] var tear_below_hp := 0.0 # fraction; reaching it forces the last state +var hp_bands := false # states split max hp into equal bands (mini bars) var normals := {} # "P" -> move name, "6P" -> forward+P, "K" -> ... var command_moves: Array = [] # [{motion:[2,3,6], button:"P", move:"rush_palm"}] var finisher := {} # {motion, button, move} — offered while foe is dizzy @@ -47,6 +48,7 @@ static func load_dir(dir_path: String) -> CharacterData: if w != null: c.wardrobe_states = w.get("states", []) c.tear_below_hp = float(w.get("tear_below_hp", 0.0)) + c.hp_bands = bool(w.get("hp_bands", false)) c.normals = d.get("normals", {}) c.command_moves = d.get("command_moves", []) c.finisher = d.get("finisher", {}) diff --git a/engine/core/fighter.gd b/engine/core/fighter.gd index 3ad044e6..91b6ce1f 100644 --- a/engine/core/fighter.gd +++ b/engine/core/fighter.gd @@ -13,6 +13,8 @@ const ANIM_FOR_STATE := { St.KO: "knockdown", St.DIZZY: "dizzy", St.DYING: "death", } +const BREAK_FLASH_TICKS := 10 + var data: CharacterData var buffer := InputBuffer.new() @@ -24,6 +26,7 @@ var stun := 0 # remaining hit/block stun ticks var hp := 1000 var finisher_ok := false # set by the engine: opponent dizzy + in range var wardrobe := 0 # index into data.wardrobe_states +var break_flash := 0 # ticks of white flash after a wardrobe advance var facing := 1 # 1 = facing right var pos_x := 0.0 var pos_y := 0.0 # 0 = on ground, negative = airborne @@ -89,6 +92,9 @@ func tick(mask: int) -> void: St.DIZZY: pass # looping daze; the engine owns the finish window + if break_flash > 0: + break_flash -= 1 + # pushback slide if absf(push_vel) > 0.05: pos_x += push_vel @@ -204,16 +210,27 @@ func take_hit(atk: MoveData, blocked: bool) -> void: _enter(St.HITSTUN) -## Clothing degrades forward only: tearing moves advance one state, -## dropping below the hp threshold forces the final state. +## Wardrobe degrades forward only, in one of two modes: +## - classic: moves with tears:true advance one state; dropping below +## tear_below_hp × max health forces the final state. +## - hp_bands: the ordered states split max hp into equal bands — each +## state IS a mini health bar, and emptying a band breaks the fighter +## through into the next look. tears / tear_below_hp are ignored. func _update_wardrobe(atk: MoveData) -> void: var n := data.wardrobe_states.size() if n == 0: return - if atk.tears: - wardrobe = mini(wardrobe + 1, n - 1) - if data.tear_below_hp > 0.0 and hp < int(data.health * data.tear_below_hp): - wardrobe = n - 1 + var prev := wardrobe + if data.hp_bands: + var band := int(float(n) * float(data.health - hp) / float(data.health)) + wardrobe = maxi(wardrobe, mini(band, n - 1)) + else: + if atk.tears: + wardrobe = mini(wardrobe + 1, n - 1) + if data.tear_below_hp > 0.0 and hp < int(data.health * data.tear_below_hp): + wardrobe = n - 1 + if wardrobe != prev: + break_flash = BREAK_FLASH_TICKS ## Name of the current wardrobe state ("" when it has no variant frames — @@ -274,6 +291,8 @@ func _apply_render() -> void: return _sprite.texture = tex _sprite.flip_h = facing == -1 + var glow := (1.0 + 1.6 * break_flash / float(BREAK_FLASH_TICKS)) if break_flash > 0 else 1.0 + _sprite.modulate = Color(glow, glow, glow) var k := data.sprite_scale _sprite.scale = Vector2(k, k) var s: Vector2 = tex.get_size() * k diff --git a/engine/core/match_engine.gd b/engine/core/match_engine.gd index 128ba338..4fab7bc8 100644 --- a/engine/core/match_engine.gd +++ b/engine/core/match_engine.gd @@ -5,12 +5,21 @@ extends Node2D const BoxOverlay := preload("res://engine/debug/box_overlay.gd") +const BAR_W := 500.0 +const BAR_H := 26.0 +const SEG_GAP := 4.0 +# One color per wardrobe band, by state index (extra states reuse the last). +const BAND_COLORS: Array = [ + Color("#4aa3e8"), Color("#e8c840"), Color("#e85040"), + Color("#9c5ae8"), Color("#50c878"), Color("#e88a2a"), Color("#b0b6c4"), +] + var p1: Fighter var p2: Fighter var roster: Array = [] var overlay: Node2D -var hud_p1: ColorRect -var hud_p2: ColorRect +var hud_segs_1: Array = [] # per-band segment dicts (see _build_bar) +var hud_segs_2: Array = [] var hud_label_1: Label var hud_label_2: Label var msg_label: Label @@ -23,19 +32,32 @@ var finish_window := -1 # >0: loser dizzy, finisher available var finisher_running := false # screenshot/demo mode for CI-style verification: godot --path . -- --shot out.png +# --demo=finisher (default): scripted special into the FINISH flow, shot at 260. +# --demo=bands: jab an idle dummy down through every wardrobe hp band to KO; +# shots along the way. --shot-ticks=a,b,c overrides when shots are taken. var shot_path := "" +var demo := "finisher" +var shot_ticks: Array = [] func _ready() -> void: for arg in OS.get_cmdline_user_args(): if arg.begins_with("--shot="): shot_path = arg.trim_prefix("--shot=") + elif arg.begins_with("--demo="): + demo = arg.trim_prefix("--demo=") + elif arg.begins_with("--shot-ticks="): + for s in arg.trim_prefix("--shot-ticks=").split(","): + shot_ticks.append(int(s)) if shot_path == "" and OS.get_environment("FOITIN_SHOT") != "": shot_path = ProjectSettings.globalize_path("user://shot.png") + if shot_ticks.is_empty(): + shot_ticks = [260] if demo == "finisher" else [200, 500, 850, 1150, 1400] + shot_ticks.sort() _build_stage() _load_fighters() _build_hud() - if shot_path != "" and p2 != null: + if shot_path != "" and p2 != null and demo == "finisher": p2.hp = 60 # demo: one special KOs -> exercises the finisher flow overlay = BoxOverlay.new() overlay.engine = self @@ -152,26 +174,8 @@ func _load_fighters() -> void: func _build_hud() -> void: - var bar_bg1 := ColorRect.new() - bar_bg1.color = Color("#3a1020") - bar_bg1.position = Vector2(40, 30) - bar_bg1.size = Vector2(500, 26) - add_child(bar_bg1) - var bar_bg2 := ColorRect.new() - bar_bg2.color = Color("#3a1020") - bar_bg2.position = Vector2(740, 30) - bar_bg2.size = Vector2(500, 26) - add_child(bar_bg2) - hud_p1 = ColorRect.new() - hud_p1.color = Color("#e8c840") - hud_p1.position = Vector2(40, 30) - hud_p1.size = Vector2(500, 26) - add_child(hud_p1) - hud_p2 = ColorRect.new() - hud_p2.color = Color("#e8c840") - hud_p2.position = Vector2(740, 30) - hud_p2.size = Vector2(500, 26) - add_child(hud_p2) + hud_segs_1 = _build_bar(Vector2(40, 30), p1, 1) + hud_segs_2 = _build_bar(Vector2(740, 30), p2, -1) hud_label_1 = Label.new() hud_label_1.position = Vector2(40, 60) add_child(hud_label_1) @@ -193,6 +197,34 @@ func _build_hud() -> void: hud_label_2.text = p2.data.display_name +## One health bar as N wardrobe-band segments — each band is a mini bar the +## attacker punches through (1 plain segment when hp_bands is off). +## drain_dir 1 = P1 style (drains right-to-left), -1 = P2 (mirrored). +func _build_bar(origin: Vector2, f: Fighter, drain_dir: int) -> Array: + var n := 1 + if f != null and f.data.hp_bands: + n = maxi(f.data.wardrobe_states.size(), 1) + var bg := ColorRect.new() + bg.color = Color("#3a1020") + bg.position = origin + bg.size = Vector2(BAR_W, BAR_H) + add_child(bg) + var seg_w := (BAR_W - SEG_GAP * (n - 1)) / n + var out: Array = [] + for i in n: # i = wardrobe state index; state 0 must drain first + var slot := (n - 1 - i) if drain_dir == 1 else i + var fg := ColorRect.new() + fg.color = BAND_COLORS[mini(i, BAND_COLORS.size() - 1)] if n > 1 else Color("#e8c840") + fg.position = Vector2(origin.x + slot * (seg_w + SEG_GAP), origin.y) + fg.size = Vector2(seg_w, BAR_H) + add_child(fg) + out.append({ + "fg": fg, "x": fg.position.x, "w": seg_w, "dir": drain_dir, + "hi": 1.0 - float(i) / n, "lo": 1.0 - float(i + 1) / n, + }) + return out + + ## ---------------------------------------------------------------- input func _poll_p1() -> int: @@ -244,7 +276,7 @@ func _physics_process(_delta: float) -> void: var m1 := _poll_p1() var m2 := _poll_p2() if shot_path != "": - m1 = _demo_input(tick_count) + m1 = _demo_input_bands(tick_count) if demo == "bands" else _demo_input(tick_count) m2 = 0 if not match_over: @@ -261,8 +293,23 @@ func _physics_process(_delta: float) -> void: _update_hud() overlay.queue_redraw() - if shot_path != "" and tick_count == 260: - _save_screenshot() + if shot_path != "" and tick_count in shot_ticks: + _save_screenshot( + shot_path if shot_ticks.size() == 1 else _tick_path(tick_count), + tick_count == shot_ticks.back()) + + +## Bands demo: walk toward the (idle) dummy and jab on a fixed rhythm — +## deterministically punches it down through every wardrobe hp band to KO. +## Forward is released on press ticks so the press reads as a neutral jab; +## the rhythm leaves slack over the jab's 26-tick total so no press lands +## inside the previous jab's recovery and gets eaten. +func _demo_input_bands(t: int) -> int: + if p2 != null and p2.hp <= 0: + return 0 # opponent down: hold still so dizzy/KO shots stay clean + if t > 60 and t % 32 == 0: + return InputBuffer.P + return 1 << 9 # raw right = toward P2 ## Scripted input for screenshot mode: walk in, QCF+P special (KOs the @@ -364,13 +411,39 @@ func _tick_finish_flow() -> void: func _update_hud() -> void: - hud_p1.size.x = 500.0 * p1.hp / p1.data.health - hud_p2.size.x = 500.0 * p2.hp / p2.data.health - hud_p2.position.x = 740 + (500 - hud_p2.size.x) + _update_bar(hud_segs_1, p1) + _update_bar(hud_segs_2, p2) + hud_label_1.text = _fighter_label(p1) + hud_label_2.text = _fighter_label(p2) -func _save_screenshot() -> void: +static func _update_bar(segs: Array, f: Fighter) -> void: + if f == null: + return + var frac := float(f.hp) / f.data.health + for s in segs: + var fill: float = clampf((frac - s.lo) / (s.hi - s.lo), 0.0, 1.0) + var fg: ColorRect = s.fg + fg.size.x = s.w * fill + if s.dir == -1: + fg.position.x = s.x + s.w * (1.0 - fill) + + +static func _fighter_label(f: Fighter) -> String: + if f == null: + return "" + if f.data.hp_bands and f.data.wardrobe_states.size() > 1: + return "%s · %s" % [f.data.display_name, str(f.wardrobe_state_name()).to_upper()] + return f.data.display_name + + +func _tick_path(t: int) -> String: + return "%s-%04d.png" % [shot_path.get_basename(), t] + + +func _save_screenshot(path: String, quit_after: bool) -> void: await RenderingServer.frame_post_draw var img := get_viewport().get_texture().get_image() - img.save_png(shot_path) - get_tree().quit() + img.save_png(path) + if quit_after: + get_tree().quit()