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>
300 lines
8.4 KiB
GDScript
300 lines
8.4 KiB
GDScript
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)
|