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)