Skip to main content

aeon_fighter/
moves.rs

1//! Frame data and move identity.
2//!
3//! Design law (Aeon/DESIGN.md, 2026-08-14):
4//! - No normal-to-normal chains. Links and special cancels only.
5//! - Whiffing a weapon-heavy is a real mistake: long recovery, no cancel.
6//! - Damage lives in 2–3 hit confirms.
7//! - Rekkas are the backbone special: parts 1–3 with branch points.
8//! - Command grabs beat both blocks and lose to the uppercut.
9//!
10//! Move ids are *slots*. Both bodies share the machinery; the character file
11//! gives each slot its name, motion, and numbers.
12
13use crate::geom::{px, LocalBox};
14use crate::input::Btn;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
17pub enum MoveId {
18    // Standing normals
19    StP,
20    StK,
21    StS,
22    StHS,
23    StHSClose,
24    StFL,
25    StST,
26    // Crouching normals (2ST is the sweep)
27    CrP,
28    CrK,
29    CrS,
30    CrHS,
31    CrFL,
32    CrST,
33    // Jumping normals (hop and full jump share them)
34    JP,
35    JK,
36    JS,
37    JHS,
38    JFL,
39    JST,
40    /// HS+ST — universal standing overhead.
41    Overhead,
42    /// P+K — normal throw.
43    Throw,
44    // Special slots
45    Rekka1,
46    Rekka2,
47    Rekka3,
48    /// 623 — the uppercut.
49    Uppercut,
50    /// 63214+FL — command grab.
51    CommandGrab,
52    /// 236+FL — command dash.
53    CommandDash,
54    /// 214+S — Kogan revolver / Raya crystal plant.
55    ShotA,
56    /// 236+HS — Kogan energy wave / Raya voice glyph.
57    ShotB,
58    /// 214+HS — Kogan disc-shield.
59    Guard,
60    /// 214+S while a crystal is planted — Raya early detonate.
61    Detonate,
62    /// 236+ST — Kogan leaping saber overhead.
63    SpecialOverhead,
64    /// j.FL — Kogan air gun.
65    AirShot,
66    /// hold 214+FL — Raya consecrate (crystal gauge buff).
67    Charge,
68    /// 236+S+HS — EX rekka / EX glyph.
69    ExA,
70    /// 214+S+HS — EX revolver / EX crystal.
71    ExB,
72    /// 236236+S
73    Super,
74}
75
76impl MoveId {
77    pub const ALL: [MoveId; 37] = [
78        MoveId::StP,
79        MoveId::StK,
80        MoveId::StS,
81        MoveId::StHS,
82        MoveId::StHSClose,
83        MoveId::StFL,
84        MoveId::StST,
85        MoveId::CrP,
86        MoveId::CrK,
87        MoveId::CrS,
88        MoveId::CrHS,
89        MoveId::CrFL,
90        MoveId::CrST,
91        MoveId::JP,
92        MoveId::JK,
93        MoveId::JS,
94        MoveId::JHS,
95        MoveId::JFL,
96        MoveId::JST,
97        MoveId::Overhead,
98        MoveId::Throw,
99        MoveId::Rekka1,
100        MoveId::Rekka2,
101        MoveId::Rekka3,
102        MoveId::Uppercut,
103        MoveId::CommandGrab,
104        MoveId::CommandDash,
105        MoveId::ShotA,
106        MoveId::ShotB,
107        MoveId::Guard,
108        MoveId::Detonate,
109        MoveId::SpecialOverhead,
110        MoveId::AirShot,
111        MoveId::Charge,
112        MoveId::ExA,
113        MoveId::ExB,
114        MoveId::Super,
115    ];
116
117    /// Generic slot label. Characters override with `Character::move_name`.
118    pub fn slot_name(self) -> &'static str {
119        match self {
120            Self::StP => "5P",
121            Self::StK => "5K",
122            Self::StS => "5S",
123            Self::StHS => "5HS",
124            Self::StHSClose => "c.HS",
125            Self::StFL => "5FL",
126            Self::StST => "5ST",
127            Self::CrP => "2P",
128            Self::CrK => "2K",
129            Self::CrS => "2S",
130            Self::CrHS => "2HS",
131            Self::CrFL => "2FL",
132            Self::CrST => "2ST",
133            Self::JP => "j.P",
134            Self::JK => "j.K",
135            Self::JS => "j.S",
136            Self::JHS => "j.HS",
137            Self::JFL => "j.FL",
138            Self::JST => "j.ST",
139            Self::Overhead => "overhead",
140            Self::Throw => "throw",
141            Self::Rekka1 => "rekka 1",
142            Self::Rekka2 => "rekka 2",
143            Self::Rekka3 => "rekka 3",
144            Self::Uppercut => "uppercut",
145            Self::CommandGrab => "command grab",
146            Self::CommandDash => "command dash",
147            Self::ShotA => "shot A",
148            Self::ShotB => "shot B",
149            Self::Guard => "guard",
150            Self::Detonate => "detonate",
151            Self::SpecialOverhead => "special overhead",
152            Self::AirShot => "air shot",
153            Self::Charge => "charge",
154            Self::ExA => "EX A",
155            Self::ExB => "EX B",
156            Self::Super => "super",
157        }
158    }
159
160    pub fn is_normal(self) -> bool {
161        matches!(
162            self,
163            Self::StP
164                | Self::StK
165                | Self::StS
166                | Self::StHS
167                | Self::StHSClose
168                | Self::StFL
169                | Self::StST
170                | Self::CrP
171                | Self::CrK
172                | Self::CrS
173                | Self::CrHS
174                | Self::CrFL
175                | Self::CrST
176                | Self::JP
177                | Self::JK
178                | Self::JS
179                | Self::JHS
180                | Self::JFL
181                | Self::JST
182                | Self::Overhead
183        )
184    }
185
186    pub fn is_crouching(self) -> bool {
187        matches!(
188            self,
189            Self::CrP | Self::CrK | Self::CrS | Self::CrHS | Self::CrFL | Self::CrST
190        )
191    }
192
193    pub fn is_jumping(self) -> bool {
194        matches!(
195            self,
196            Self::JP | Self::JK | Self::JS | Self::JHS | Self::JFL | Self::JST | Self::AirShot
197        )
198    }
199
200    pub fn is_rekka(self) -> bool {
201        matches!(self, Self::Rekka1 | Self::Rekka2 | Self::Rekka3)
202    }
203
204    pub fn is_ex(self) -> bool {
205        matches!(self, Self::ExA | Self::ExB)
206    }
207
208    pub fn is_super(self) -> bool {
209        self == Self::Super
210    }
211
212    pub fn is_special(self) -> bool {
213        !self.is_normal() && self != Self::Throw
214    }
215}
216
217#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
218pub enum HitLevel {
219    /// Must stand-block. Jump-ins, overheads.
220    High,
221    /// Blocked either way.
222    Mid,
223    /// Must crouch-block. Sweeps, 2K.
224    Low,
225}
226
227#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
228pub enum CancelRule {
229    Never,
230    /// Special/super cancel only on hit.
231    OnHit,
232    /// Special/super cancel on hit or block (lights, some mids).
233    OnHitOrBlock,
234}
235
236#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
237pub enum ThrowKind {
238    None,
239    /// P+K. Beats both blocks. Techable. Hit beats it.
240    Normal,
241    /// 63214+FL. Beats both blocks. Untechable. Loses to uppercut invuln.
242    Command,
243}
244
245#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
246pub struct Invuln {
247    pub start: u8,
248    pub end: u8,
249    pub strike: bool,
250    pub throw: bool,
251}
252
253impl Invuln {
254    pub const NONE: Self = Self {
255        start: 0,
256        end: 0,
257        strike: false,
258        throw: false,
259    };
260
261    /// Full invulnerability (strike + throw) on frames `start..end`.
262    pub const fn full(start: u8, end: u8) -> Self {
263        Self {
264            start,
265            end,
266            strike: true,
267            throw: true,
268        }
269    }
270
271    pub fn strike_on(self, frame: u16) -> bool {
272        self.strike && frame >= self.start as u16 && frame < self.end as u16
273    }
274
275    pub fn throw_on(self, frame: u16) -> bool {
276        self.throw && frame >= self.start as u16 && frame < self.end as u16
277    }
278}
279
280#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
281pub struct TimedBox {
282    pub start: u8,
283    pub end: u8,
284    pub hit: LocalBox,
285}
286
287impl TimedBox {
288    pub const fn span(start: u8, end: u8, hit: LocalBox) -> Self {
289        Self { start, end, hit }
290    }
291}
292
293/// A rekka branch: pressing `button` during frames `from..to` of the current
294/// part starts `next`. Follow-ups are legal on hit, block, and whiff — the
295/// pause and the spacing are the skill.
296#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
297pub struct Followup {
298    pub button: Btn,
299    pub next: MoveId,
300    pub from: u8,
301    pub to: u8,
302}
303
304/// Hold-to-channel definition (Raya consecrate). While the button stays held
305/// on the channel frame, the move does not advance and the gauge fills.
306#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
307pub struct ChannelDef {
308    pub button: Btn,
309    pub max_frames: u16,
310    pub gauge_per_frame: i32,
311}
312
313#[derive(Clone, Debug)]
314pub struct MoveDef {
315    pub id: MoveId,
316    pub startup: u8,
317    pub active: u8,
318    pub recovery: u8,
319    pub damage: i32,
320    pub chip: i32,
321    pub hitstun: u8,
322    pub blockstun: u8,
323    pub hitstop: u8,
324    pub blockstop: u8,
325    pub pushback_hit: i32,
326    pub pushback_block: i32,
327    pub level: HitLevel,
328    pub knockdown: bool,
329    pub launch: i32,
330    pub cancel: CancelRule,
331    pub invuln: Invuln,
332    pub hitboxes: &'static [TimedBox],
333    /// Horizontal velocity applied on frame 0 (dash attacks, DP rise).
334    pub vel_x: i32,
335    /// Frames the horizontal velocity lasts on the ground. 0 = whole move.
336    pub vel_frames: u8,
337    pub vel_y: i32,
338    pub gravity_override: Option<i32>,
339    /// Extra landing recovery if this move leaves the ground (the uppercut tax).
340    pub land_recovery: u8,
341    pub projectile: Option<ProjectileDef>,
342    /// Active hitboxes on this move destroy opposing projectiles.
343    pub projectile_guard: bool,
344    pub meter_on_hit: i32,
345    pub meter_cost: i32,
346    /// Character gauge spent on start (EX, revolver chambers).
347    pub gauge_cost: i32,
348    pub throw: ThrowKind,
349    pub followups: &'static [Followup],
350    /// FL+ST during startup cancels this special to nothing.
351    pub feintable: bool,
352    /// Movement special that passes through the opponent's pushbox.
353    pub pass_through: bool,
354    pub channel: Option<ChannelDef>,
355}
356
357impl MoveDef {
358    pub fn total_frames(&self) -> u16 {
359        self.startup as u16 + self.active as u16 + self.recovery as u16
360    }
361
362    pub fn first_active(&self) -> u16 {
363        self.startup as u16
364    }
365
366    pub fn last_active(&self) -> u16 {
367        self.startup as u16 + self.active as u16
368    }
369
370    pub fn is_active(&self, frame: u16) -> bool {
371        frame >= self.first_active() && frame < self.last_active()
372    }
373
374    pub fn in_startup(&self, frame: u16) -> bool {
375        frame < self.first_active()
376    }
377
378    pub fn in_recovery(&self, frame: u16) -> bool {
379        frame >= self.last_active() && frame < self.total_frames()
380    }
381
382    pub fn finished(&self, frame: u16) -> bool {
383        frame >= self.total_frames()
384    }
385
386    /// Advantage if the move connects on its first active frame.
387    pub fn advantage_on_hit(&self) -> i32 {
388        self.hitstun as i32 - (self.active as i32 - 1) - self.recovery as i32
389    }
390
391    pub fn advantage_on_block(&self) -> i32 {
392        self.blockstun as i32 - (self.active as i32 - 1) - self.recovery as i32
393    }
394
395    pub fn hitboxes_on(&self, frame: u16) -> impl Iterator<Item = LocalBox> + '_ {
396        self.hitboxes
397            .iter()
398            .filter(move |tb| frame >= tb.start as u16 && frame < tb.end as u16)
399            .map(|tb| tb.hit)
400    }
401
402    pub fn followup_for(&self, button: Btn, frame: u16) -> Option<MoveId> {
403        self.followups
404            .iter()
405            .find(|f| f.button == button && frame >= f.from as u16 && frame < f.to as u16)
406            .map(|f| f.next)
407    }
408
409    pub fn is_throw(&self) -> bool {
410        self.throw != ThrowKind::None
411    }
412
413    /// Reasonable defaults for a special: everything off, no boxes.
414    pub fn special(id: MoveId) -> Self {
415        Self {
416            id,
417            startup: 0,
418            active: 0,
419            recovery: 0,
420            damage: 0,
421            chip: 0,
422            hitstun: 0,
423            blockstun: 0,
424            hitstop: 8,
425            blockstop: 6,
426            pushback_hit: px(10),
427            pushback_block: px(8),
428            level: HitLevel::Mid,
429            knockdown: false,
430            launch: 0,
431            cancel: CancelRule::Never,
432            invuln: Invuln::NONE,
433            hitboxes: &[],
434            vel_x: 0,
435            vel_frames: 0,
436            vel_y: 0,
437            gravity_override: None,
438            land_recovery: 0,
439            projectile: None,
440            projectile_guard: false,
441            meter_on_hit: 0,
442            meter_cost: 0,
443            gauge_cost: 0,
444            throw: ThrowKind::None,
445            followups: &[],
446            feintable: !id.is_super() && id != MoveId::Throw,
447            pass_through: false,
448            channel: None,
449        }
450    }
451}
452
453/// How a shot lives once spawned.
454#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
455pub enum ShotBehavior {
456    /// Moves at `vel` until it hits, clashes, leaves the stage, or expires.
457    Travel,
458    /// Spawns and stays put (Raya's voice glyph, Kogan's wave is Travel).
459    Hang,
460    /// Arcs under gravity, lands, arms after `arm_after`, then detonates on
461    /// contact or when `armed_life` runs out (harmlessly).
462    Plant { arm_after: u16, armed_life: u16 },
463}
464
465#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
466pub struct ProjectileDef {
467    pub kind: ProjectileKind,
468    pub behavior: ShotBehavior,
469    /// Spawn offset from the owner's origin, facing-relative.
470    pub spawn: LocalBox,
471    pub vel_x: i32,
472    pub vel_y: i32,
473    pub gravity: i32,
474    pub lifetime: u16,
475    pub damage: i32,
476    pub chip: i32,
477    pub hitstun: u8,
478    pub blockstun: u8,
479    pub hitstop: u8,
480    pub pushback: i32,
481    pub hitbox: LocalBox,
482    /// Hitbox used when a planted crystal detonates.
483    pub blast: Option<LocalBox>,
484    pub level: HitLevel,
485    pub knockdown: bool,
486}
487
488#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
489pub enum ProjectileKind {
490    Revolver,
491    Wave,
492    AirShot,
493    Glyph,
494    Crystal,
495}
496
497impl ProjectileKind {
498    pub fn clash_class(self) -> ProjectileClass {
499        match self {
500            Self::Revolver | Self::AirShot | Self::Glyph => ProjectileClass::Light,
501            Self::Wave | Self::Crystal => ProjectileClass::Heavy,
502        }
503    }
504
505    pub fn name(self) -> &'static str {
506        match self {
507            Self::Revolver => "revolver",
508            Self::Wave => "wave",
509            Self::AirShot => "air gun",
510            Self::Glyph => "glyph",
511            Self::Crystal => "crystal",
512        }
513    }
514}
515
516#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
517pub enum ProjectileClass {
518    Light,
519    Heavy,
520}
521
522/// Hitstop / pushback class by button family.
523fn button_class(id: MoveId) -> (u8, i32, i32) {
524    match id {
525        MoveId::StP | MoveId::StK | MoveId::CrP | MoveId::CrK | MoveId::JP | MoveId::JK => {
526            (7, px(6), px(8))
527        }
528        MoveId::StS | MoveId::StFL | MoveId::CrS | MoveId::CrFL | MoveId::JS | MoveId::JFL => {
529            (8, px(12), px(10))
530        }
531        _ => (10, px(14), px(10)),
532    }
533}
534
535/// Shared helper so character files stay readable.
536#[allow(clippy::too_many_arguments)]
537pub fn normal(
538    id: MoveId,
539    startup: u8,
540    active: u8,
541    recovery: u8,
542    damage: i32,
543    hitstun: u8,
544    blockstun: u8,
545    level: HitLevel,
546    cancel: CancelRule,
547    hit: LocalBox,
548) -> MoveDef {
549    let (hitstop, push_hit, push_block) = button_class(id);
550    let mut m = MoveDef::special(id);
551    m.startup = startup;
552    m.active = active;
553    m.recovery = recovery;
554    m.damage = damage;
555    m.hitstun = hitstun;
556    m.blockstun = blockstun;
557    m.hitstop = hitstop;
558    m.blockstop = hitstop.saturating_sub(2);
559    m.pushback_hit = push_hit;
560    m.pushback_block = push_block;
561    m.level = level;
562    m.knockdown = id == MoveId::CrST;
563    m.cancel = cancel;
564    m.hitboxes = leak_box(startup, startup + active, hit);
565    m.meter_on_hit = damage / 8;
566    m.feintable = false;
567    m
568}
569
570pub fn leak_box(start: u8, end: u8, hit: LocalBox) -> &'static [TimedBox] {
571    // Character data is process-lifetime. Fine for a data-driven fighter.
572    Box::leak(Box::new([TimedBox::span(start, end, hit)]))
573}
574
575pub fn leak_boxes(boxes: Vec<TimedBox>) -> &'static [TimedBox] {
576    Box::leak(boxes.into_boxed_slice())
577}
578
579pub fn leak_followups(f: Vec<Followup>) -> &'static [Followup] {
580    Box::leak(f.into_boxed_slice())
581}