foitin/engine/core/fighter.gd
type-two c1eca9e1a0 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>
2026-08-16 15:11:36 +10:00

300 lines
8.4 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name Fighter extends Node2D
## One fighter: deterministic FSM advanced by tick(input_mask).
## All gameplay state lives in plain vars updated at 60Hz — rendering
## just reads the current move + tick. Keep it this way: determinism
## from inputs is what keeps rollback netcode possible later.
enum St { IDLE, WALK_F, WALK_B, CROUCH, JUMP, ATTACK, BLOCKSTUN, HITSTUN, KNOCKDOWN, GETUP, KO, DIZZY, DYING }
const ANIM_FOR_STATE := {
St.IDLE: "idle", St.WALK_F: "walk_f", St.WALK_B: "walk_b",
St.CROUCH: "crouch", St.JUMP: "jump", St.BLOCKSTUN: "block",
St.HITSTUN: "hit", St.KNOCKDOWN: "knockdown", St.GETUP: "getup",
St.KO: "knockdown", St.DIZZY: "dizzy", St.DYING: "death",
}
const BREAK_FLASH_TICKS := 10
var data: CharacterData
var buffer := InputBuffer.new()
var state: int = St.IDLE
var anim_tick := 0 # ticks into the current animation/move
var attack_move: MoveData = null
var has_hit := false # current attack already connected
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
var vel_y := 0.0
var push_vel := 0.0 # pushback slide, decays
var _sprite: Sprite2D
func setup(char_data: CharacterData, start_x: float, face: int) -> void:
data = char_data
hp = data.health
pos_x = start_x
facing = face
_sprite = Sprite2D.new()
_sprite.centered = false
add_child(_sprite)
_apply_render()
func airborne() -> bool:
return pos_y < 0.0 or state == St.JUMP
func can_act() -> bool:
return state in [St.IDLE, St.WALK_F, St.WALK_B, St.CROUCH]
func blocking_high(mask: int) -> bool:
return can_act() and (mask & InputBuffer.BACK) != 0
func current_move() -> MoveData:
if state == St.ATTACK:
return attack_move
var anim: String = ANIM_FOR_STATE[state]
return data.moves.get(anim, data.moves.get("idle"))
## ------------------------------------------------------------------ tick
func tick(mask: int) -> void:
buffer.push(mask)
anim_tick += 1
match state:
St.IDLE, St.WALK_F, St.WALK_B, St.CROUCH:
_tick_neutral(mask)
St.JUMP:
_tick_airborne()
St.ATTACK:
_tick_attack()
St.BLOCKSTUN, St.HITSTUN:
_tick_stun()
St.KNOCKDOWN:
if anim_tick >= current_move().total:
_enter(St.GETUP)
St.GETUP:
if anim_tick >= current_move().total:
_enter(St.IDLE)
St.KO, St.DYING:
pass # play out and hold the final frame
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
push_vel *= 0.72
pos_x = clampf(pos_x, -CFG.STAGE_HALF, CFG.STAGE_HALF)
_apply_render()
func _tick_neutral(mask: int) -> void:
# attacks take priority
if _try_attack(mask):
return
if mask & InputBuffer.UP:
vel_y = data.jump_velocity
_enter(St.JUMP)
return
if mask & InputBuffer.DOWN:
_set_state(St.CROUCH)
return
if mask & InputBuffer.FWD:
_set_state(St.WALK_F)
pos_x += data.walk_speed * facing
elif mask & InputBuffer.BACK:
_set_state(St.WALK_B)
pos_x -= data.back_speed * facing
else:
_set_state(St.IDLE)
func _try_attack(mask: int) -> bool:
var p := buffer.pressed(InputBuffer.P)
var k := buffer.pressed(InputBuffer.K)
if not (p or k):
return false
# finisher first — only offered while the opponent is dizzy and close
if finisher_ok and data.finisher.size() > 0:
var fbtn: int = InputBuffer.P if data.finisher.get("button", "K") == "P" else InputBuffer.K
if buffer.pressed(fbtn) and buffer.motion(data.finisher.get("motion", [])):
if _start_attack(data.finisher.get("move", "finisher")):
return true
# command moves next (e.g. QCF+P)
for cm in data.command_moves:
var btn: int = InputBuffer.P if cm.get("button", "P") == "P" else InputBuffer.K
if buffer.pressed(btn) and buffer.motion(cm.get("motion", [])):
return _start_attack(cm.get("move", ""))
# directional normals (6P etc.), then plain
var dir_prefix := ""
if mask & InputBuffer.FWD:
dir_prefix = "6"
elif mask & InputBuffer.DOWN:
dir_prefix = "2"
var btn_name := "P" if p else "K"
if data.normals.has(dir_prefix + btn_name):
return _start_attack(data.normals[dir_prefix + btn_name])
if data.normals.has(btn_name):
return _start_attack(data.normals[btn_name])
return false
func _start_attack(move_name: String) -> bool:
if not data.moves.has(move_name):
return false
attack_move = data.moves[move_name]
has_hit = false
_set_state(St.ATTACK)
anim_tick = 0
return true
func _tick_attack() -> void:
pos_x += attack_move.root_dx(anim_tick) * facing
if anim_tick >= attack_move.total:
attack_move = null
_enter(St.IDLE)
func _tick_airborne() -> void:
vel_y += CFG.GRAVITY
pos_y += vel_y
if pos_y >= 0.0:
pos_y = 0.0
vel_y = 0.0
_enter(St.IDLE)
func _tick_stun() -> void:
stun -= 1
if stun <= 0:
_enter(St.IDLE)
## ---------------------------------------------------------------- events
func take_hit(atk: MoveData, blocked: bool) -> void:
if state == St.DIZZY:
# a finisher executes; any other hit just collapses them
_enter(St.DYING if atk.finisher else St.KO)
return
push_vel = atk.pushback * -facing * (0.6 if blocked else 1.0) / CFG.PUSHBACK_TICKS * 2.0
if blocked:
stun = atk.blockstun
_enter(St.BLOCKSTUN)
return
hp = maxi(hp - atk.damage, 0)
_update_wardrobe(atk)
if hp <= 0:
_enter(St.DIZZY) # FINISH window — the engine decides the ending
elif atk.knockdown:
_enter(St.KNOCKDOWN)
else:
stun = atk.hitstun
_enter(St.HITSTUN)
## 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
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 —
## states named "base" simply fall back to the default frame set).
func wardrobe_state_name() -> String:
if wardrobe >= data.wardrobe_states.size():
return ""
return data.wardrobe_states[wardrobe]
func _enter(s: int) -> void:
state = s
anim_tick = 0
## Keep anim_tick when staying in the same looping state (idle/walk).
func _set_state(s: int) -> void:
if state != s:
_enter(s)
## ---------------------------------------------------------------- boxes
## Local rect (right-facing) -> world rect, honouring facing and sprite scale.
func to_world(r: Rect2) -> Rect2:
var k := data.sprite_scale
var rs := Rect2(r.position * k, r.size * k)
var x := pos_x + rs.position.x if facing == 1 else pos_x - rs.position.x - rs.size.x
return Rect2(x, CFG.GROUND_Y + pos_y + rs.position.y, rs.size.x, rs.size.y)
func world_hurtboxes() -> Array:
var m := current_move()
var out: Array = []
for r in m.hurtboxes_at(anim_tick):
out.append(to_world(r))
return out
func world_hitboxes() -> Array:
if state != St.ATTACK or has_hit or not attack_move.is_active(anim_tick):
return []
var out: Array = []
for r in attack_move.hitboxes_at(anim_tick):
out.append(to_world(r))
return out
## ---------------------------------------------------------------- render
func _apply_render() -> void:
position = Vector2(pos_x, CFG.GROUND_Y + pos_y)
var m := current_move()
if _sprite == null or m == null:
return
var tex = m.frame_texture(anim_tick, wardrobe_state_name())
if tex == null:
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
_sprite.position = Vector2(-s.x / 2.0, -s.y)