foitin/engine/core/fighter.gd
m3ultra d743216b54 Character select screen + fatality-style finisher system
- Select scene (new main scene): roster cards from characters/* with
  portraits, dual cursors (P1 A/D+J, P2 arrows+O), picks via GameState
- finisher flow: KO puts loser in DIZZY with a 5s flashing FINISH! window;
  winner lands QCF+K in range -> finisher move (Brutal Assassination /
  Hell Slammer B) with darkened stage, victim plays synced death anim;
  window expiry or any stray hit = normal collapse
- new moves rendered for both fighters in all wardrobe states:
  dizzy (Stunned), finisher, death (Standing Death Backward)
- CHAR_TUNE per-character move overrides in tune_fighters.py

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:27:51 +10:00

281 lines
7.7 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",
}
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 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
# 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)
## Clothing degrades forward only: tearing moves advance one state,
## dropping below the hp threshold forces the final state.
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
## 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 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)