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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-16 15:11:36 +10:00
parent 4e2004e740
commit c1eca9e1a0
5 changed files with 150 additions and 39 deletions

View File

@ -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@<state>/` 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/<move>/data.json
```json

View File

@ -13,7 +13,7 @@
"base",
"torn"
],
"tear_below_hp": 0.35
"hp_bands": true
},
"normals": {
"P": "jab",

View File

@ -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", {})

View File

@ -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

View File

@ -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()