- pipeline/render_moves.py: headless Blender 5.x renderer — imports rigged FBX/GLB, remaps Mixamo bone namespaces (mixamorigN:), fixes FBX axis/scale, normalises height, centres ground pivot, renders transparent ortho sprites - engine: sprite_scale in manifest (scales sprite + all boxes), --p1/--p2 roster picks for testing - characters/trainer: woman_athleisure_01 (Mixamo, web-ok) rendered through the full pipeline as integration proof — idle/jab/hit from NPC clips, silhouette auto-hurtboxes, verified in-game vs alpha Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
6.1 KiB
GDScript
246 lines
6.1 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 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)
|
|
if hp <= 0:
|
|
_enter(St.KO)
|
|
elif atk.knockdown:
|
|
_enter(St.KNOCKDOWN)
|
|
else:
|
|
stun = atk.hitstun
|
|
_enter(St.HITSTUN)
|
|
|
|
|
|
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)
|
|
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)
|