Skip to main content

aeon_fighter/
fighter.rs

1//! Per-fighter state machine.
2//!
3//! Input priority (ground, not locked):
4//!   chord (throw / EX / overhead) → special / super → normal → jump / hop
5//!   → backdash / run → walk / crouch / stand
6//!
7//! Locked states (hitstun, blockstun, attack, knockdown, prejump, thrown,
8//! feint) eat the tick except for cancels, rekka follow-ups, feints, and
9//! Roman Cancel.
10
11use crate::chars::{Character, CharacterId};
12use crate::geom::{px, Aabb, LocalBox, Vec2i};
13use crate::input::{Btn, Buttons, Chord, InputBuffer, InputFrame, Motion, CHORD_WINDOW};
14use crate::moves::{CancelRule, HitLevel, MoveDef, MoveId, ThrowKind};
15
16pub const METER_MAX: i32 = 1000;
17/// Frames of prejump. Releasing up before the last prejump frame hops.
18pub const PREJUMP: u8 = 4;
19pub const LANDING_RECOVERY: u8 = 2;
20/// A hop itself adds no landing lock. Air specials still owe their own tax.
21pub const HOP_LANDING_RECOVERY: u8 = 0;
22/// Hard knockdown: 32 down + 24 getup = 56 frames of oki. The currency.
23pub const GETUP_FRAMES: u16 = 24;
24pub const KNOCKDOWN_FRAMES: u16 = 32;
25/// A grabbed defender may tech a normal throw for this many frames.
26pub const THROW_TECH_WINDOW: u8 = 7;
27/// A command grab holds this long before it resolves (no tech).
28pub const COMMAND_GRAB_HOLD: u8 = 4;
29pub const THROW_TECH_FRAMES: u16 = 16;
30pub const BACKDASH_FRAMES: u16 = 14;
31pub const RC_COST: i32 = 250;
32pub const RC_FREEZE_FRAMES: u8 = 6;
33/// Feint: special startup cancelled to nothing, then this many frames.
34pub const FEINT_RECOVERY: u16 = 8;
35/// Special cancels are legal from first active through this many frames
36/// after the last active frame. Confirmed, not fished.
37pub const CANCEL_LATE_FRAMES: u16 = 2;
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40pub enum Stance {
41    Stand,
42    Crouch,
43    Air,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq, Hash)]
47pub enum Action {
48    Stand,
49    Crouch,
50    Walk {
51        forward: bool,
52    },
53    Run,
54    BackDash {
55        frame: u16,
56    },
57    Prejump {
58        frame: u8,
59        dir_x: i32,
60        hop: bool,
61    },
62    Jump {
63        air_ok: bool,
64        hop: bool,
65    },
66    Attack {
67        move_id: MoveId,
68        frame: u16,
69        connected: Connect,
70    },
71    /// FL+ST during a special's startup: the special never came.
72    Feint {
73        frame: u16,
74    },
75    Block {
76        crouching: bool,
77        stun: u8,
78    },
79    Hit {
80        stun: u8,
81        knockdown: bool,
82    },
83    Knockdown {
84        frame: u16,
85    },
86    Getup {
87        frame: u16,
88    },
89    /// Grabbed. Resolves into damage + knockdown unless teched in time.
90    Thrown {
91        frame: u8,
92        techable: bool,
93        damage: i32,
94        meter: i32,
95    },
96    ThrowTech {
97        frame: u16,
98    },
99    Landing {
100        frame: u16,
101        total: u16,
102    },
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
106pub enum Connect {
107    None,
108    Hit,
109    Block,
110}
111
112impl Action {
113    pub fn attacking(&self) -> Option<(MoveId, u16, Connect)> {
114        match *self {
115            Action::Attack {
116                move_id,
117                frame,
118                connected,
119            } => Some((move_id, frame, connected)),
120            _ => None,
121        }
122    }
123
124    pub fn in_hitstun(&self) -> bool {
125        matches!(self, Action::Hit { .. })
126    }
127
128    pub fn in_blockstun(&self) -> bool {
129        matches!(self, Action::Block { .. })
130    }
131
132    pub fn actionable(&self) -> bool {
133        matches!(
134            self,
135            Action::Stand
136                | Action::Crouch
137                | Action::Walk { .. }
138                | Action::Run
139                | Action::Jump { air_ok: true, .. }
140        )
141    }
142
143    pub fn is_hop(&self) -> bool {
144        matches!(
145            self,
146            Action::Jump { hop: true, .. } | Action::Prejump { hop: true, .. }
147        )
148    }
149
150    /// Cannot be thrown: airborne handled separately; stunned, downed,
151    /// already grabbed, or teching bodies are throw-protected.
152    pub fn throw_protected(&self) -> bool {
153        matches!(
154            self,
155            Action::Hit { .. }
156                | Action::Block { .. }
157                | Action::Knockdown { .. }
158                | Action::Getup { .. }
159                | Action::Thrown { .. }
160                | Action::ThrowTech { .. }
161        )
162    }
163
164    pub fn name(&self) -> &'static str {
165        match self {
166            Action::Stand => "stand",
167            Action::Crouch => "crouch",
168            Action::Walk { forward: true } => "walk",
169            Action::Walk { forward: false } => "backwalk",
170            Action::Run => "run",
171            Action::BackDash { .. } => "backdash",
172            Action::Prejump { hop: true, .. } => "prehop",
173            Action::Prejump { .. } => "prejump",
174            Action::Jump { hop: true, .. } => "hop",
175            Action::Jump { .. } => "jump",
176            Action::Attack { move_id, .. } => move_id.slot_name(),
177            Action::Feint { .. } => "feint",
178            Action::Block { .. } => "block",
179            Action::Hit { .. } => "hitstun",
180            Action::Knockdown { .. } => "down",
181            Action::Getup { .. } => "getup",
182            Action::Thrown { .. } => "thrown",
183            Action::ThrowTech { .. } => "tech",
184            Action::Landing { .. } => "land",
185        }
186    }
187}
188
189#[derive(Clone, Debug, PartialEq, Eq, Hash)]
190pub struct Fighter {
191    pub id: CharacterId,
192    pub pos: Vec2i,
193    pub vel: Vec2i,
194    pub facing_right: bool,
195    pub health: i32,
196    pub meter: i32,
197    /// Character gauge: Kogan chambers, Raya crystal charge.
198    pub gauge: i32,
199    /// Frames since the gauge was last spent (drives cooldown regen).
200    pub gauge_idle: u16,
201    pub action: Action,
202    pub buffer: InputBuffer,
203    pub airborne: bool,
204    /// The jump arc survives an air attack, its recovery, and Roman Cancel.
205    pub hop: bool,
206    pub combo: u8,
207    pub juggle: u8,
208    pub last_move: Option<MoveId>,
209    pub gravity_override: Option<i32>,
210    /// Landing recovery owed by the current airborne move (uppercut tax).
211    pub land_recovery: u8,
212    /// Frames spent holding the current channel (consecrate).
213    pub channel_frames: u16,
214    /// Button edges and chords that landed during hitstop / RC freeze, so a
215    /// cancel pressed inside the freeze is not lost.
216    pub frozen_presses: Buttons,
217    pub frozen_chord: Option<Chord>,
218    /// World writes these before `tick_pre_collision`.
219    pub last_distance: i32,
220    pub has_planted: bool,
221}
222
223impl Fighter {
224    pub fn spawn(id: CharacterId, x: i32, facing_right: bool) -> Self {
225        let data = id.data();
226        Self {
227            id,
228            pos: Vec2i::new(x, 0),
229            vel: Vec2i::ZERO,
230            facing_right,
231            health: data.max_health,
232            meter: 0,
233            gauge: data.gauge.start,
234            gauge_idle: 0,
235            action: Action::Stand,
236            buffer: InputBuffer::default(),
237            airborne: false,
238            hop: false,
239            combo: 0,
240            juggle: 0,
241            last_move: None,
242            gravity_override: None,
243            land_recovery: 0,
244            channel_frames: 0,
245            frozen_presses: Buttons::default(),
246            frozen_chord: None,
247            last_distance: px(200),
248            has_planted: false,
249        }
250    }
251
252    pub fn data(&self) -> &'static Character {
253        self.id.data()
254    }
255
256    pub fn input(&self) -> InputFrame {
257        self.buffer.latest()
258    }
259
260    /// Button edges this tick, including any that landed during a freeze.
261    pub fn pressed_now(&self) -> Buttons {
262        self.buffer.pressed().or(self.frozen_presses)
263    }
264
265    pub fn chord_now(&self) -> Option<Chord> {
266        self.buffer.chord().or(self.frozen_chord)
267    }
268
269    /// Raya's buff tier from the crystal gauge (0 for bodies without one).
270    pub fn buff_tier(&self) -> i32 {
271        let step = self.data().gauge.buff_step;
272        if step <= 0 {
273            0
274        } else {
275            self.gauge / step
276        }
277    }
278
279    pub fn pushbox(&self) -> Aabb {
280        let d = self.data();
281        let h = match self.hurt_stance() {
282            Stance::Crouch => d.crouch_h,
283            Stance::Air => d.stand_h * 4 / 5,
284            Stance::Stand => d.stand_h,
285        };
286        Aabb::from_center_size(self.pos.x, self.pos.y, d.push_w, h)
287    }
288
289    pub fn hurtboxes(&self) -> Vec<Aabb> {
290        let d = self.data();
291        let local = match self.hurt_stance() {
292            Stance::Crouch => d.hurt_crouch(),
293            Stance::Air => {
294                let h = if self.hop {
295                    d.stand_h * 7 / 10
296                } else {
297                    d.stand_h * 4 / 5
298                };
299                LocalBox::new(-d.push_w / 2, px(8), d.push_w + px(6), h)
300            }
301            Stance::Stand => d.hurt_stand(),
302        };
303        vec![local.to_world(self.pos, self.facing_right)]
304    }
305
306    /// Visual silhouette outside the hittable body. The renderer may draw
307    /// this, but collision intentionally never reads it.
308    pub fn visual_aura_box(&self) -> Option<Aabb> {
309        self.data()
310            .aura
311            .map(|aura| aura.to_world(self.pos, self.facing_right))
312    }
313
314    pub fn hurt_stance(&self) -> Stance {
315        if self.airborne {
316            return Stance::Air;
317        }
318        match &self.action {
319            Action::Crouch
320            | Action::Block {
321                crouching: true, ..
322            } => Stance::Crouch,
323            Action::Attack { move_id, .. } if move_id.is_crouching() => Stance::Crouch,
324            Action::Knockdown { .. } | Action::Getup { .. } => Stance::Crouch,
325            _ => Stance::Stand,
326        }
327    }
328
329    pub fn current_move(&self) -> Option<&'static MoveDef> {
330        self.action
331            .attacking()
332            .and_then(|(id, _, _)| self.data().move_def(id))
333    }
334
335    pub fn strike_invuln(&self) -> bool {
336        if let Action::Attack { move_id, frame, .. } = self.action {
337            if let Some(mv) = self.data().move_def(move_id) {
338                return mv.invuln.strike_on(frame);
339            }
340        }
341        // No OTG: a downed body is not hittable until it has risen.
342        matches!(
343            self.action,
344            Action::Knockdown { .. } | Action::Getup { .. } | Action::ThrowTech { .. }
345        )
346    }
347
348    pub fn throw_invuln(&self) -> bool {
349        if let Action::Attack { move_id, frame, .. } = self.action {
350            if let Some(mv) = self.data().move_def(move_id) {
351                return mv.invuln.throw_on(frame);
352            }
353        }
354        false
355    }
356
357    /// True while a pass-through movement special is running.
358    pub fn passing_through(&self) -> bool {
359        self.current_move().map(|m| m.pass_through).unwrap_or(false)
360    }
361
362    pub fn hitboxes(&self) -> Vec<Aabb> {
363        let Some((id, frame, connected)) = self.action.attacking() else {
364            return Vec::new();
365        };
366        if connected != Connect::None {
367            // One hit per move.
368            return Vec::new();
369        }
370        let Some(mv) = self.data().move_def(id) else {
371            return Vec::new();
372        };
373        mv.hitboxes_on(frame)
374            .map(|b| b.to_world(self.pos, self.facing_right))
375            .collect()
376    }
377
378    pub fn would_block(&self, level: HitLevel) -> bool {
379        if self.airborne {
380            return false;
381        }
382        if !matches!(
383            self.action,
384            Action::Stand
385                | Action::Crouch
386                | Action::Walk { .. }
387                | Action::Run
388                | Action::Block { .. }
389        ) {
390            return false;
391        }
392        let inp = self.input();
393        if !inp.back() {
394            return false;
395        }
396        match level {
397            HitLevel::Low => inp.down(),
398            HitLevel::High => !inp.down(),
399            HitLevel::Mid => true,
400        }
401    }
402
403    pub fn add_meter(&mut self, amount: i32) {
404        self.meter = (self.meter + amount).clamp(0, METER_MAX);
405    }
406
407    pub fn spend_meter(&mut self, amount: i32) -> bool {
408        if self.meter >= amount {
409            self.meter -= amount;
410            true
411        } else {
412            false
413        }
414    }
415
416    pub fn add_gauge(&mut self, amount: i32) {
417        let max = self.data().gauge.max;
418        self.gauge = (self.gauge + amount).clamp(0, max);
419    }
420
421    pub fn spend_gauge(&mut self, amount: i32) -> bool {
422        if amount <= 0 {
423            return true;
424        }
425        if self.gauge >= amount {
426            self.gauge -= amount;
427            self.gauge_idle = 0;
428            true
429        } else {
430            false
431        }
432    }
433
434    pub fn face_toward(&mut self, other_x: i32) {
435        if matches!(
436            self.action,
437            Action::Attack { .. }
438                | Action::Feint { .. }
439                | Action::Hit { .. }
440                | Action::Block { .. }
441                | Action::Knockdown { .. }
442                | Action::Getup { .. }
443                | Action::Thrown { .. }
444                | Action::ThrowTech { .. }
445                | Action::Prejump { .. }
446                | Action::BackDash { .. }
447        ) {
448            return;
449        }
450        if other_x != self.pos.x {
451            self.facing_right = other_x > self.pos.x;
452        }
453    }
454
455    pub fn buffer_input(&mut self, raw: InputFrame) {
456        self.buffer.push(raw);
457    }
458
459    /// Called by World on frozen ticks so edges inside hitstop survive.
460    pub fn accumulate_frozen(&mut self) {
461        self.frozen_presses = self.frozen_presses.or(self.buffer.pressed());
462        if let Some(c) = self.buffer.chord() {
463            self.frozen_chord = Some(c);
464        }
465    }
466
467    fn clear_frozen(&mut self) {
468        self.frozen_presses = Buttons::default();
469        self.frozen_chord = None;
470    }
471
472    /// RC cancels an attack after hit, block, or whiff. It does not burst out
473    /// of defense: hitstun and blockstun remain binding.
474    pub fn try_roman_cancel(&mut self) -> bool {
475        if self.chord_now() != Some(Chord::RomanCancel) {
476            return false;
477        }
478        let Action::Attack { move_id, .. } = self.action else {
479            return false;
480        };
481        if move_id == MoveId::Throw || move_id == MoveId::CommandGrab {
482            return false;
483        }
484        if !self.spend_meter(RC_COST) {
485            return false;
486        }
487        self.clear_frozen();
488        self.vel = Vec2i::ZERO;
489        self.gravity_override = None;
490        self.land_recovery = 0;
491        self.action = if self.airborne {
492            Action::Jump {
493                air_ok: true,
494                hop: self.hop,
495            }
496        } else {
497            Action::Stand
498        };
499        true
500    }
501
502    /// Step the state machine. Input was buffered by `World` first so global
503    /// hitstop and Roman Cancel can inspect the same deterministic frame.
504    pub fn tick_pre_collision(&mut self, stage_w: i32) {
505        self.tick_gauge();
506        self.step_action();
507        self.integrate(stage_w);
508        self.clear_frozen();
509    }
510
511    fn tick_gauge(&mut self) {
512        let g = self.data().gauge;
513        self.gauge_idle = self.gauge_idle.saturating_add(1);
514        if g.regen_amount > 0
515            && self.gauge < g.max
516            && self.gauge_idle >= g.regen_delay
517            && g.regen_every > 0
518            && (self.gauge_idle - g.regen_delay).is_multiple_of(g.regen_every)
519        {
520            self.add_gauge(g.regen_amount);
521        }
522    }
523
524    fn step_action(&mut self) {
525        match self.action.clone() {
526            Action::Hit { stun, knockdown } => self.step_hit(stun, knockdown),
527            Action::Block { crouching, stun } => {
528                if stun == 0 {
529                    self.resume_control();
530                } else {
531                    self.action = Action::Block {
532                        crouching,
533                        stun: stun.saturating_sub(1),
534                    };
535                }
536            }
537            Action::Knockdown { frame } => {
538                self.airborne = false;
539                self.pos.y = 0;
540                self.vel = Vec2i::ZERO;
541                if frame + 1 >= KNOCKDOWN_FRAMES {
542                    self.action = Action::Getup { frame: 0 };
543                } else {
544                    self.action = Action::Knockdown { frame: frame + 1 };
545                }
546            }
547            Action::Getup { frame } => {
548                if frame + 1 >= GETUP_FRAMES {
549                    self.resume_control();
550                } else {
551                    self.action = Action::Getup { frame: frame + 1 };
552                }
553            }
554            // Resolved by World (needs both fighters).
555            Action::Thrown { .. } => {
556                self.vel = Vec2i::ZERO;
557            }
558            Action::ThrowTech { frame } => {
559                self.vel.x = if self.facing_right { -px(4) } else { px(4) };
560                if frame + 1 >= THROW_TECH_FRAMES {
561                    self.vel.x = 0;
562                    self.resume_control();
563                } else {
564                    self.action = Action::ThrowTech { frame: frame + 1 };
565                }
566            }
567            Action::Landing { frame, total } => {
568                self.vel = Vec2i::ZERO;
569                if frame + 1 >= total {
570                    self.resume_control();
571                } else {
572                    self.action = Action::Landing {
573                        frame: frame + 1,
574                        total,
575                    };
576                }
577            }
578            Action::Feint { frame } => {
579                self.vel.x = 0;
580                if frame + 1 >= FEINT_RECOVERY {
581                    self.resume_control();
582                } else {
583                    self.action = Action::Feint { frame: frame + 1 };
584                }
585            }
586            Action::Prejump { frame, dir_x, hop } => {
587                // Tap/hold: letting go of up before the jump leaves the
588                // ground makes it a hop.
589                let hop = hop || !self.input().up();
590                if frame + 1 >= PREJUMP {
591                    self.begin_jump(dir_x, hop);
592                } else {
593                    self.action = Action::Prejump {
594                        frame: frame + 1,
595                        dir_x,
596                        hop,
597                    };
598                }
599            }
600            Action::BackDash { frame } => {
601                if frame == 0 {
602                    self.vel.x = if self.facing_right { -px(7) } else { px(7) };
603                }
604                if frame + 1 >= BACKDASH_FRAMES {
605                    self.vel.x = 0;
606                    self.resume_control();
607                } else {
608                    if frame > 8 {
609                        self.vel.x = self.vel.x * 3 / 4;
610                    }
611                    self.action = Action::BackDash { frame: frame + 1 };
612                }
613            }
614            Action::Attack {
615                move_id,
616                frame,
617                connected,
618            } => self.step_attack(move_id, frame, connected),
619            Action::Jump { air_ok, hop } => {
620                let gravity = self.gravity_override.unwrap_or(self.data().gravity);
621                if air_ok && self.pos.y + self.vel.y - gravity > 0 {
622                    self.try_air_offense(hop);
623                }
624            }
625            Action::Run => {
626                if !self.try_ground_offense() {
627                    let inp = self.input();
628                    if inp.up() {
629                        self.action = Action::Prejump {
630                            frame: 0,
631                            dir_x: 1,
632                            hop: false,
633                        };
634                    } else if !inp.forward() || inp.down() {
635                        self.vel.x = 0;
636                        self.idle_from_input();
637                    } else {
638                        let spd = self.data().run_speed;
639                        self.vel.x = if self.facing_right { spd } else { -spd };
640                        self.action = Action::Run;
641                    }
642                }
643            }
644            Action::Stand | Action::Crouch | Action::Walk { .. } => {
645                if !self.try_ground_offense() {
646                    self.ground_locomotion();
647                }
648            }
649        }
650    }
651
652    fn step_hit(&mut self, stun: u8, knockdown: bool) {
653        if stun == 0 {
654            if self.airborne {
655                // A launched body rides the arc: air hitstun ends on the
656                // floor, never in the air. There is no air recovery, so the
657                // uppercut's knockdown is a promise, not a race.
658                self.action = Action::Hit { stun: 0, knockdown };
659            } else if knockdown {
660                self.action = Action::Knockdown { frame: 0 };
661            } else {
662                self.resume_control();
663            }
664        } else {
665            self.action = Action::Hit {
666                stun: stun - 1,
667                knockdown,
668            };
669        }
670    }
671
672    fn step_attack(&mut self, move_id: MoveId, frame: u16, connected: Connect) {
673        let Some(mv) = self.data().move_def(move_id).cloned() else {
674            self.idle_from_input();
675            return;
676        };
677
678        if let Some(chord) = self.chord_now() {
679            match chord {
680                Chord::Feint if mv.feintable && mv.in_startup(frame) => {
681                    self.vel = Vec2i::ZERO;
682                    self.gravity_override = None;
683                    self.land_recovery = 0;
684                    self.channel_frames = 0;
685                    self.action = Action::Feint { frame: 0 };
686                    return;
687                }
688                // Kara: the first button of a chord started a move a frame or
689                // two ago. The chord replaces it and refunds what it spent.
690                Chord::Throw | Chord::Ex | Chord::Overhead
691                    if !self.airborne
692                        && connected == Connect::None
693                        && frame < CHORD_WINDOW as u16
694                        && !matches!(
695                            mv.id,
696                            MoveId::Throw
697                                | MoveId::Overhead
698                                | MoveId::ExA
699                                | MoveId::ExB
700                                | MoveId::Super
701                        ) =>
702                {
703                    let refund_gauge = mv.gauge_cost;
704                    let refund_meter = mv.meter_cost;
705                    self.add_gauge(refund_gauge);
706                    self.add_meter(refund_meter);
707                    if self.try_chord(chord) {
708                        return;
709                    }
710                    let _ = self.spend_gauge(refund_gauge);
711                    let _ = self.spend_meter(refund_meter);
712                }
713                _ => {}
714            }
715        }
716
717        let pressed = self.pressed_now();
718
719        // Rekka follow-ups: legal on hit, block, or whiff inside the window.
720        if pressed.any() && !mv.followups.is_empty() {
721            for b in pressed.iter() {
722                if let Some(next) = mv.followup_for(b, frame) {
723                    self.start_move(next);
724                    return;
725                }
726            }
727        }
728
729        if self.try_cancel(&mv, frame, connected, pressed) {
730            return;
731        }
732
733        // Channel (consecrate): hold the button on the channel frame.
734        if let Some(ch) = mv.channel {
735            if frame == mv.first_active()
736                && self.input().buttons.get(ch.button)
737                && self.channel_frames < ch.max_frames
738            {
739                self.channel_frames += 1;
740                self.add_gauge(ch.gauge_per_frame);
741                return;
742            }
743        }
744
745        let next = frame + 1;
746        if mv.vel_frames > 0 && next >= mv.vel_frames as u16 && !self.airborne {
747            self.vel.x = 0;
748        }
749        if mv.finished(next) {
750            self.gravity_override = None;
751            self.channel_frames = 0;
752            if self.airborne {
753                self.action = Action::Jump {
754                    air_ok: false,
755                    hop: self.hop,
756                };
757            } else {
758                self.vel.x = 0;
759                self.resume_control();
760            }
761        } else {
762            self.action = Action::Attack {
763                move_id,
764                frame: next,
765                connected,
766            };
767        }
768    }
769
770    fn try_cancel(
771        &mut self,
772        mv: &MoveDef,
773        frame: u16,
774        connected: Connect,
775        pressed: Buttons,
776    ) -> bool {
777        let can = match (mv.cancel, connected) {
778            (CancelRule::Never, _) => false,
779            (CancelRule::OnHit, Connect::Hit) => true,
780            (CancelRule::OnHitOrBlock, Connect::Hit | Connect::Block) => true,
781            _ => false,
782        };
783        if !can || frame < mv.first_active() || frame > mv.last_active() + CANCEL_LATE_FRAMES {
784            return false;
785        }
786        let ex = self.chord_now() == Some(Chord::Ex);
787        if !pressed.any() && !ex {
788            return false;
789        }
790        if let Some(id) = self.data().match_special(
791            self.airborne,
792            pressed,
793            ex,
794            self.meter,
795            self.gauge,
796            &self.buffer,
797        ) {
798            if id.is_rekka() && id != MoveId::Rekka1 {
799                return false;
800            }
801            self.start_move(id);
802            return true;
803        }
804        false
805    }
806
807    /// Throw / EX / overhead from a neutral-ish state.
808    fn try_chord(&mut self, chord: Chord) -> bool {
809        match chord {
810            Chord::Throw => {
811                if self.data().has_move(MoveId::Throw) {
812                    self.start_move(MoveId::Throw);
813                    return true;
814                }
815            }
816            Chord::Overhead => {
817                if self.data().has_move(MoveId::Overhead) {
818                    self.start_move(MoveId::Overhead);
819                    return true;
820                }
821            }
822            Chord::Ex => {
823                if let Some(id) = self.data().match_special(
824                    false,
825                    Buttons::default(),
826                    true,
827                    self.meter,
828                    self.gauge,
829                    &self.buffer,
830                ) {
831                    self.start_move(id);
832                    return true;
833                }
834            }
835            _ => {}
836        }
837        false
838    }
839
840    fn try_ground_offense(&mut self) -> bool {
841        let inp = self.input();
842        let pressed = self.pressed_now();
843
844        if let Some(chord) = self.chord_now() {
845            if self.try_chord(chord) {
846                return true;
847            }
848        }
849
850        if pressed.any() {
851            let mut id = self.data().match_special(
852                false,
853                pressed,
854                false,
855                self.meter,
856                self.gauge,
857                &self.buffer,
858            );
859            // Plant-or-detonate: the shot slot detonates a planted crystal.
860            if id == Some(MoveId::ShotA)
861                && self.has_planted
862                && self.data().has_move(MoveId::Detonate)
863            {
864                id = Some(MoveId::Detonate);
865            }
866            if let Some(id) = id {
867                if id.is_rekka() && id != MoveId::Rekka1 {
868                    // Parts 2–3 only follow part 1.
869                } else {
870                    self.start_move(id);
871                    return true;
872                }
873            }
874            if let Some(id) = self.pick_normal(pressed) {
875                self.start_move(id);
876                return true;
877            }
878        }
879
880        if inp.up() {
881            let dir_x = if inp.forward() {
882                1
883            } else if inp.back() {
884                -1
885            } else {
886                0
887            };
888            self.action = Action::Prejump {
889                frame: 0,
890                dir_x,
891                hop: false,
892            };
893            self.vel.x = 0;
894            return true;
895        }
896
897        if self.buffer.motion(Motion::BackDash) && !self.airborne {
898            self.action = Action::BackDash { frame: 0 };
899            return true;
900        }
901        if self.buffer.motion(Motion::ForwardDash) && inp.forward() && !inp.down() {
902            self.action = Action::Run;
903            let spd = self.data().run_speed;
904            self.vel.x = if self.facing_right { spd } else { -spd };
905            return true;
906        }
907        false
908    }
909
910    fn try_air_offense(&mut self, _hop: bool) {
911        let pressed = self.pressed_now();
912        if !pressed.any() {
913            return;
914        }
915        if let Some(id) =
916            self.data()
917                .match_special(true, pressed, false, self.meter, self.gauge, &self.buffer)
918        {
919            self.start_move(id);
920            return;
921        }
922        if let Some(id) = self.pick_normal(pressed) {
923            self.start_move(id);
924        }
925    }
926
927    fn pick_normal(&self, pressed: Buttons) -> Option<MoveId> {
928        // Priority when two land the same frame: heavier family first, so a
929        // sloppy chord still yields the bigger button.
930        for b in [Btn::HS, Btn::ST, Btn::S, Btn::FL, Btn::K, Btn::P] {
931            if !pressed.get(b) {
932                continue;
933            }
934            if let Some(id) = self.data().select_normal(
935                self.input().down() && !self.airborne,
936                self.airborne,
937                b,
938                self.last_distance,
939            ) {
940                return Some(id);
941            }
942        }
943        None
944    }
945
946    fn ground_locomotion(&mut self) {
947        let inp = self.input();
948        let d = self.data();
949        if inp.down() {
950            self.vel.x = 0;
951            self.action = Action::Crouch;
952            return;
953        }
954        if inp.forward() {
955            self.vel.x = if self.facing_right {
956                d.walk_fwd
957            } else {
958                -d.walk_fwd
959            };
960            self.action = Action::Walk { forward: true };
961        } else if inp.back() {
962            self.vel.x = if self.facing_right {
963                -d.walk_back
964            } else {
965                d.walk_back
966            };
967            self.action = Action::Walk { forward: false };
968        } else {
969            self.vel.x = 0;
970            self.action = Action::Stand;
971        }
972    }
973
974    fn idle_from_input(&mut self) {
975        self.gravity_override = None;
976        if self.airborne {
977            self.action = Action::Jump {
978                air_ok: false,
979                hop: self.hop,
980            };
981        } else {
982            self.ground_locomotion();
983        }
984    }
985
986    /// The first free frame accepts its input. This does not buffer a press
987    /// from the preceding recovery frame or introduce normal chains.
988    fn resume_control(&mut self) {
989        self.idle_from_input();
990        if !self.airborne {
991            self.try_ground_offense();
992        }
993    }
994
995    fn begin_jump(&mut self, dir_x: i32, hop: bool) {
996        let d = self.data();
997        let signed = if self.facing_right { dir_x } else { -dir_x };
998        if hop {
999            self.vel.x = signed * d.hop_x;
1000            self.vel.y = d.hop_y;
1001        } else {
1002            self.vel.x = signed * d.jump_x;
1003            self.vel.y = d.jump_y;
1004        }
1005        self.airborne = true;
1006        self.hop = hop;
1007        self.action = Action::Jump { air_ok: true, hop };
1008    }
1009
1010    pub fn start_move(&mut self, id: MoveId) {
1011        self.last_move = Some(id);
1012        self.channel_frames = 0;
1013        if let Some(mv) = self.data().move_def(id) {
1014            // Air normals and air gun inherit the jump's horizontal travel.
1015            // Only a move with authored movement replaces that trajectory.
1016            if !self.airborne || mv.vel_x != 0 {
1017                self.vel.x = if self.facing_right { mv.vel_x } else { -mv.vel_x };
1018            }
1019            if mv.vel_y != 0 {
1020                self.vel.y = mv.vel_y;
1021                self.airborne = true;
1022                self.hop = false;
1023                self.land_recovery = mv.land_recovery;
1024            }
1025            self.gravity_override = mv.gravity_override;
1026            if mv.meter_cost > 0 {
1027                let _ = self.spend_meter(mv.meter_cost);
1028            }
1029            if mv.gauge_cost > 0 {
1030                let _ = self.spend_gauge(mv.gauge_cost);
1031            }
1032        }
1033        self.action = Action::Attack {
1034            move_id: id,
1035            frame: 0,
1036            connected: Connect::None,
1037        };
1038    }
1039
1040    fn integrate(&mut self, stage_w: i32) {
1041        if self.airborne {
1042            let g = self.gravity_override.unwrap_or(self.data().gravity);
1043            self.vel.y -= g;
1044        }
1045        self.pos.x += self.vel.x;
1046        self.pos.y += self.vel.y;
1047
1048        let half = self.data().push_w / 2;
1049        self.pos.x = self.pos.x.clamp(half, stage_w - half);
1050
1051        if self.pos.y <= 0 {
1052            self.pos.y = 0;
1053            if self.airborne {
1054                self.airborne = false;
1055                self.vel = Vec2i::ZERO;
1056                self.gravity_override = None;
1057                match self.action {
1058                    Action::Hit { knockdown: true, .. } => {
1059                        self.action = Action::Knockdown { frame: 0 };
1060                    }
1061                    // A juggled body whose stun ran out in the air lands
1062                    // like a full jump does.
1063                    Action::Hit { stun: 0, .. } => {
1064                        self.action = Action::Landing {
1065                            frame: 0,
1066                            total: LANDING_RECOVERY as u16,
1067                        };
1068                    }
1069                    // Touching ground must not erase remaining air hitstun.
1070                    Action::Hit { .. } => {}
1071                    _ => {
1072                        let base = if self.hop { HOP_LANDING_RECOVERY } else { LANDING_RECOVERY };
1073                        let total = base.max(self.land_recovery) as u16;
1074                        if total == 0 {
1075                            self.resume_control();
1076                        } else {
1077                            self.action = Action::Landing { frame: 0, total };
1078                        }
1079                    }
1080                }
1081                self.land_recovery = 0;
1082                self.hop = false;
1083            }
1084        }
1085    }
1086
1087    pub fn mark_connected(&mut self, hit: bool) {
1088        if let Action::Attack { connected, .. } = &mut self.action {
1089            *connected = if hit { Connect::Hit } else { Connect::Block };
1090        }
1091    }
1092
1093    pub fn apply_hit(
1094        &mut self,
1095        stun: u8,
1096        knockdown: bool,
1097        launch: i32,
1098        push: i32,
1099        from_right: bool,
1100    ) {
1101        self.combo = self.combo.saturating_add(1);
1102        let dir = if from_right { -1 } else { 1 };
1103        // Pushback is a one-shot displacement. Leaving it as velocity
1104        // would slide the defender out of link range over the whole stun.
1105        self.pos.x += dir * push;
1106        if launch > 0 {
1107            self.vel.x = dir * (push / 4);
1108            self.vel.y = launch;
1109            self.airborne = true;
1110        } else if !self.airborne {
1111            self.vel = Vec2i::ZERO;
1112        }
1113        self.action = Action::Hit { stun, knockdown };
1114        self.hop = false;
1115        self.gravity_override = None;
1116        self.land_recovery = 0;
1117        self.channel_frames = 0;
1118    }
1119
1120    pub fn apply_block(&mut self, stun: u8, push: i32, from_right: bool, crouching: bool) {
1121        self.combo = 0;
1122        let dir = if from_right { -1 } else { 1 };
1123        self.pos.x += dir * push;
1124        if !self.airborne {
1125            self.vel = Vec2i::ZERO;
1126        }
1127        self.action = Action::Block { crouching, stun };
1128    }
1129
1130    pub fn apply_grab(&mut self, kind: ThrowKind, damage: i32, meter: i32) {
1131        self.vel = Vec2i::ZERO;
1132        self.gravity_override = None;
1133        self.channel_frames = 0;
1134        self.action = Action::Thrown {
1135            frame: 0,
1136            techable: kind == ThrowKind::Normal,
1137            damage,
1138            meter,
1139        };
1140    }
1141
1142    pub fn reset_combo(&mut self) {
1143        self.combo = 0;
1144        self.juggle = 0;
1145    }
1146}