foitin/engine/core/fighter.gd
m3ultra 347804135d Gear wardrobe state (Phase B): vesper geared with MODELBEAST boxing gloves + headgear
- gear GLBs generated on MODELBEAST (flux product shot -> bg_remove ->
  trellis_mac), staged in foitin_assets/gear/
- pipeline: --attach bone-parents rigid gear (armature-scale compensated,
  '|'-separated spec); render_geared.sh holds calibrated transforms
- vesper: 3-state degradation geared -> base -> torn; specials knock gear
  off first, then shred clothes; kachujin stays 2-state (per-character data)
- spec doc: wardrobe section in CHARACTER_SPEC.md

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

268 lines
6.9 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 }
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",
}
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 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:
pass
# 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
# command moves first (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:
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.KO)
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)