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:
parent
4e2004e740
commit
c1eca9e1a0
@ -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
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"base",
|
||||
"torn"
|
||||
],
|
||||
"tear_below_hp": 0.35
|
||||
"hp_bands": true
|
||||
},
|
||||
"normals": {
|
||||
"P": "jab",
|
||||
|
||||
@ -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", {})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user