Skip to main content

proof_engine/combat/
combo.rs

1//! Combo system — input buffering, combo chains, and hit-confirm windows.
2
3use std::collections::VecDeque;
4
5// ── ComboInput ────────────────────────────────────────────────────────────────
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum ComboInput {
9    LightAttack,
10    HeavyAttack,
11    Special,
12    Dodge,
13    Block,
14    Jump,
15    Ability1,
16    Ability2,
17    Ability3,
18    Ability4,
19    Direction(ComboDirection),
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum ComboDirection {
24    Forward,
25    Backward,
26    Up,
27    Down,
28    Neutral,
29}
30
31impl ComboInput {
32    pub fn name(self) -> &'static str {
33        match self {
34            ComboInput::LightAttack  => "Light",
35            ComboInput::HeavyAttack  => "Heavy",
36            ComboInput::Special      => "Special",
37            ComboInput::Dodge        => "Dodge",
38            ComboInput::Block        => "Block",
39            ComboInput::Jump         => "Jump",
40            ComboInput::Ability1     => "Skill1",
41            ComboInput::Ability2     => "Skill2",
42            ComboInput::Ability3     => "Skill3",
43            ComboInput::Ability4     => "Skill4",
44            ComboInput::Direction(_) => "Dir",
45        }
46    }
47}
48
49// ── BufferedInput ─────────────────────────────────────────────────────────────
50
51#[derive(Debug, Clone)]
52pub struct BufferedInput {
53    pub input:     ComboInput,
54    pub timestamp: f64,
55    pub consumed:  bool,
56}
57
58/// Ring buffer of recent inputs for combo detection.
59#[derive(Debug, Clone)]
60pub struct InputBuffer {
61    inputs:          VecDeque<BufferedInput>,
62    max_size:        usize,
63    buffer_window:   f64,  // seconds — inputs older than this are expired
64    current_time:    f64,
65}
66
67impl InputBuffer {
68    pub fn new(max_size: usize, buffer_window: f64) -> Self {
69        Self {
70            inputs: VecDeque::with_capacity(max_size),
71            max_size,
72            buffer_window,
73            current_time: 0.0,
74        }
75    }
76
77    pub fn update(&mut self, dt: f32) {
78        self.current_time += dt as f64;
79        // Expire old inputs
80        while let Some(front) = self.inputs.front() {
81            if self.current_time - front.timestamp > self.buffer_window {
82                self.inputs.pop_front();
83            } else {
84                break;
85            }
86        }
87    }
88
89    pub fn push(&mut self, input: ComboInput) {
90        if self.inputs.len() >= self.max_size {
91            self.inputs.pop_front();
92        }
93        self.inputs.push_back(BufferedInput {
94            input,
95            timestamp: self.current_time,
96            consumed: false,
97        });
98    }
99
100    pub fn consume_next_unconsumed(&mut self) -> Option<ComboInput> {
101        for entry in &mut self.inputs {
102            if !entry.consumed {
103                entry.consumed = true;
104                return Some(entry.input);
105            }
106        }
107        None
108    }
109
110    pub fn peek_sequence(&self, len: usize) -> Vec<ComboInput> {
111        self.inputs.iter()
112            .filter(|e| !e.consumed)
113            .map(|e| e.input)
114            .take(len)
115            .collect()
116    }
117
118    pub fn clear(&mut self) {
119        self.inputs.clear();
120    }
121
122    pub fn len(&self) -> usize {
123        self.inputs.iter().filter(|e| !e.consumed).count()
124    }
125
126    pub fn is_empty(&self) -> bool { self.len() == 0 }
127}
128
129// ── ComboLink ─────────────────────────────────────────────────────────────────
130
131#[derive(Debug, Clone)]
132pub struct ComboLink {
133    pub input:         ComboInput,
134    /// Maximum seconds after the previous hit's confirm window opens
135    pub time_window:   f32,
136    /// Minimum seconds (prevents mashing being too easy)
137    pub min_delay:     f32,
138}
139
140impl ComboLink {
141    pub fn new(input: ComboInput, window: f32) -> Self {
142        Self { input, time_window: window, min_delay: 0.0 }
143    }
144
145    pub fn with_min_delay(mut self, d: f32) -> Self { self.min_delay = d; self }
146}
147
148// ── ComboHit ──────────────────────────────────────────────────────────────────
149
150#[derive(Debug, Clone)]
151pub struct ComboHit {
152    pub animation_id:     String,
153    pub damage_multiplier: f32,
154    pub hitstop_duration:  f32,  // freeze frames on hit
155    pub launch:            bool,
156    pub knockback_force:   f32,
157    pub can_cancel_into:   Vec<String>,  // combo IDs you can cancel into
158    pub hit_confirm_start: f32,   // when the hitbox is active (relative to hit time)
159    pub hit_confirm_end:   f32,
160    pub glyph:             char,
161    pub element:           Option<super::Element>,
162}
163
164impl ComboHit {
165    pub fn new(anim: impl Into<String>, dmg_mult: f32) -> Self {
166        Self {
167            animation_id: anim.into(),
168            damage_multiplier: dmg_mult,
169            hitstop_duration: 0.1,
170            launch: false,
171            knockback_force: 0.0,
172            can_cancel_into: Vec::new(),
173            hit_confirm_start: 0.3,
174            hit_confirm_end: 0.5,
175            glyph: '✦',
176            element: None,
177        }
178    }
179
180    pub fn heavy(anim: impl Into<String>, dmg_mult: f32) -> Self {
181        Self {
182            animation_id: anim.into(),
183            damage_multiplier: dmg_mult,
184            hitstop_duration: 0.2,
185            launch: false,
186            knockback_force: 5.0,
187            can_cancel_into: Vec::new(),
188            hit_confirm_start: 0.4,
189            hit_confirm_end: 0.7,
190            glyph: '⚡',
191            element: None,
192        }
193    }
194
195    pub fn launcher(anim: impl Into<String>) -> Self {
196        Self {
197            animation_id: anim.into(),
198            damage_multiplier: 0.8,
199            hitstop_duration: 0.15,
200            launch: true,
201            knockback_force: 12.0,
202            can_cancel_into: Vec::new(),
203            hit_confirm_start: 0.35,
204            hit_confirm_end: 0.6,
205            glyph: '↑',
206            element: None,
207        }
208    }
209
210    pub fn with_element(mut self, el: super::Element) -> Self { self.element = Some(el); self }
211    pub fn with_cancel(mut self, combo_id: impl Into<String>) -> Self {
212        self.can_cancel_into.push(combo_id.into()); self
213    }
214}
215
216// ── Combo ─────────────────────────────────────────────────────────────────────
217
218#[derive(Debug, Clone)]
219pub struct Combo {
220    pub id:     String,
221    pub name:   String,
222    pub links:  Vec<ComboLink>,  // input sequence to execute this combo
223    pub hits:   Vec<ComboHit>,   // one per step in the chain
224    pub ender:  Option<ComboEnder>,
225    pub requires_airborne: bool,
226    pub requires_grounded: bool,
227    pub priority: u32,  // higher = checked first
228}
229
230#[derive(Debug, Clone)]
231pub struct ComboEnder {
232    pub name: String,
233    pub damage_multiplier: f32,
234    pub special_effect: Option<String>,
235}
236
237impl Combo {
238    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
239        Self {
240            id: id.into(),
241            name: name.into(),
242            links: Vec::new(),
243            hits: Vec::new(),
244            ender: None,
245            requires_airborne: false,
246            requires_grounded: false,
247            priority: 0,
248        }
249    }
250
251    pub fn add_link(mut self, input: ComboInput, window: f32) -> Self {
252        self.links.push(ComboLink::new(input, window));
253        self
254    }
255
256    pub fn add_hit(mut self, hit: ComboHit) -> Self {
257        self.hits.push(hit);
258        self
259    }
260
261    pub fn with_ender(mut self, name: impl Into<String>, dmg_mult: f32) -> Self {
262        self.ender = Some(ComboEnder { name: name.into(), damage_multiplier: dmg_mult, special_effect: None });
263        self
264    }
265
266    pub fn aerial(mut self) -> Self { self.requires_airborne = true; self }
267    pub fn grounded(mut self) -> Self { self.requires_grounded = true; self }
268    pub fn with_priority(mut self, p: u32) -> Self { self.priority = p; self }
269
270    pub fn total_damage_multiplier(&self) -> f32 {
271        let base: f32 = self.hits.iter().map(|h| h.damage_multiplier).sum();
272        let ender_mult = self.ender.as_ref().map(|e| e.damage_multiplier).unwrap_or(1.0);
273        base * ender_mult
274    }
275
276    pub fn input_sequence(&self) -> Vec<ComboInput> {
277        self.links.iter().map(|l| l.input).collect()
278    }
279
280    /// Check if a given input history matches this combo's sequence.
281    pub fn matches_sequence(&self, inputs: &[ComboInput]) -> bool {
282        if inputs.len() < self.links.len() { return false; }
283        let start = inputs.len() - self.links.len();
284        inputs[start..].iter().zip(self.links.iter()).all(|(inp, link)| *inp == link.input)
285    }
286}
287
288// ── ComboDatabase ─────────────────────────────────────────────────────────────
289
290#[derive(Debug, Clone, Default)]
291pub struct ComboDatabase {
292    combos: Vec<Combo>,
293}
294
295impl ComboDatabase {
296    pub fn new() -> Self { Self { combos: Vec::new() } }
297
298    pub fn register(&mut self, combo: Combo) {
299        self.combos.push(combo);
300        // Sort by priority descending, then by length (longer = more specific)
301        self.combos.sort_by(|a, b| {
302            b.priority.cmp(&a.priority).then(b.links.len().cmp(&a.links.len()))
303        });
304    }
305
306    pub fn find_matching(&self, inputs: &[ComboInput], airborne: bool, grounded: bool) -> Option<&Combo> {
307        self.combos.iter().find(|c| {
308            c.matches_sequence(inputs)
309                && (!c.requires_airborne || airborne)
310                && (!c.requires_grounded || grounded)
311        })
312    }
313
314    pub fn get(&self, id: &str) -> Option<&Combo> {
315        self.combos.iter().find(|c| c.id == id)
316    }
317
318    pub fn len(&self) -> usize { self.combos.len() }
319
320    /// Prebuilt combo database for a warrior-type character.
321    pub fn warrior_presets() -> Self {
322        let mut db = ComboDatabase::new();
323
324        db.register(
325            Combo::new("light_chain", "Light Chain")
326                .add_link(ComboInput::LightAttack, 0.5)
327                .add_link(ComboInput::LightAttack, 0.5)
328                .add_link(ComboInput::LightAttack, 0.5)
329                .add_hit(ComboHit::new("slash_1", 0.8))
330                .add_hit(ComboHit::new("slash_2", 0.9))
331                .add_hit(ComboHit::new("slash_3", 1.1))
332                .with_ender("Final Slash", 1.3)
333                .grounded()
334                .with_priority(1)
335        );
336
337        db.register(
338            Combo::new("heavy_opener", "Overhead Smash")
339                .add_link(ComboInput::HeavyAttack, 0.8)
340                .add_hit(ComboHit::heavy("overhead_smash", 1.8))
341                .grounded()
342                .with_priority(2)
343        );
344
345        db.register(
346            Combo::new("light_into_heavy", "Rapid Crush")
347                .add_link(ComboInput::LightAttack, 0.5)
348                .add_link(ComboInput::LightAttack, 0.5)
349                .add_link(ComboInput::HeavyAttack, 0.6)
350                .add_hit(ComboHit::new("slash_1", 0.7))
351                .add_hit(ComboHit::new("slash_2", 0.8))
352                .add_hit(ComboHit::heavy("crush", 2.2))
353                .with_ender("Shockwave", 1.5)
354                .grounded()
355                .with_priority(3)
356        );
357
358        db.register(
359            Combo::new("launcher", "Rising Strike")
360                .add_link(ComboInput::LightAttack, 0.5)
361                .add_link(ComboInput::Direction(ComboDirection::Up), 0.3)
362                .add_link(ComboInput::HeavyAttack, 0.5)
363                .add_hit(ComboHit::new("rising_1", 0.6))
364                .add_hit(ComboHit::launcher("launch_hit"))
365                .grounded()
366                .with_priority(4)
367        );
368
369        db.register(
370            Combo::new("air_combo", "Aerial Rave")
371                .add_link(ComboInput::LightAttack, 0.5)
372                .add_link(ComboInput::LightAttack, 0.5)
373                .add_link(ComboInput::LightAttack, 0.5)
374                .add_hit(ComboHit::new("air_slash_1", 0.7))
375                .add_hit(ComboHit::new("air_slash_2", 0.8))
376                .add_hit(ComboHit::new("air_slash_3", 1.0))
377                .with_ender("Air Slam", 2.0)
378                .aerial()
379                .with_priority(5)
380        );
381
382        db
383    }
384}
385
386// ── ComboState ────────────────────────────────────────────────────────────────
387
388#[derive(Debug, Clone, PartialEq)]
389pub enum ComboPhase {
390    Idle,
391    Attacking { hit_index: usize },
392    HitConfirmWindow { hit_index: usize, window_remaining: f32 },
393    Hitstop { duration_remaining: f32, resume_to_index: usize },
394    Ender,
395    Recovery { duration_remaining: f32 },
396}
397
398#[derive(Debug, Clone)]
399pub struct ComboState {
400    pub active_combo:   Option<String>,  // combo ID
401    pub current_phase:  ComboPhase,
402    pub hit_count:      u32,
403    pub total_damage:   f32,
404    pub combo_timer:    f32,       // time since last hit (for timeout)
405    pub combo_timeout:  f32,       // max seconds between hits
406    pub input_buffer:   InputBuffer,
407    pub is_airborne:    bool,
408    pub is_grounded:    bool,
409}
410
411impl ComboState {
412    pub fn new() -> Self {
413        Self {
414            active_combo: None,
415            current_phase: ComboPhase::Idle,
416            hit_count: 0,
417            total_damage: 0.0,
418            combo_timer: 0.0,
419            combo_timeout: 2.0,
420            input_buffer: InputBuffer::new(16, 0.3),
421            is_airborne: false,
422            is_grounded: true,
423        }
424    }
425
426    pub fn update(&mut self, dt: f32) {
427        self.input_buffer.update(dt);
428        self.combo_timer += dt;
429
430        // Timeout active combo
431        if self.active_combo.is_some() && self.combo_timer > self.combo_timeout {
432            self.reset();
433            return;
434        }
435
436        // Advance phase timers
437        match &mut self.current_phase {
438            ComboPhase::HitConfirmWindow { window_remaining, .. } => {
439                *window_remaining -= dt;
440                if *window_remaining <= 0.0 {
441                    self.current_phase = ComboPhase::Idle;
442                }
443            }
444            ComboPhase::Hitstop { duration_remaining, resume_to_index } => {
445                *duration_remaining -= dt;
446                if *duration_remaining <= 0.0 {
447                    let idx = *resume_to_index;
448                    self.current_phase = ComboPhase::HitConfirmWindow {
449                        hit_index: idx,
450                        window_remaining: 0.4,
451                    };
452                }
453            }
454            ComboPhase::Recovery { duration_remaining } => {
455                *duration_remaining -= dt;
456                if *duration_remaining <= 0.0 {
457                    self.current_phase = ComboPhase::Idle;
458                }
459            }
460            _ => {}
461        }
462    }
463
464    pub fn register_input(&mut self, input: ComboInput) {
465        self.input_buffer.push(input);
466        self.combo_timer = 0.0;
467    }
468
469    pub fn register_hit(&mut self, damage: f32, hit: &ComboHit) {
470        self.hit_count += 1;
471        self.total_damage += damage;
472        let next_idx = match &self.current_phase {
473            ComboPhase::Attacking { hit_index } => *hit_index + 1,
474            ComboPhase::HitConfirmWindow { hit_index, .. } => *hit_index + 1,
475            _ => 0,
476        };
477        self.current_phase = ComboPhase::Hitstop {
478            duration_remaining: hit.hitstop_duration,
479            resume_to_index: next_idx,
480        };
481    }
482
483    pub fn is_in_combo(&self) -> bool { self.active_combo.is_some() }
484
485    pub fn start_combo(&mut self, combo_id: String) {
486        self.active_combo = Some(combo_id);
487        self.current_phase = ComboPhase::Attacking { hit_index: 0 };
488        self.hit_count = 0;
489        self.total_damage = 0.0;
490        self.combo_timer = 0.0;
491    }
492
493    pub fn end_combo(&mut self) {
494        self.current_phase = ComboPhase::Recovery { duration_remaining: 0.5 };
495        self.active_combo = None;
496    }
497
498    pub fn reset(&mut self) {
499        self.active_combo = None;
500        self.current_phase = ComboPhase::Idle;
501        self.hit_count = 0;
502        self.total_damage = 0.0;
503        self.combo_timer = 0.0;
504        self.input_buffer.clear();
505    }
506
507    pub fn can_act(&self) -> bool {
508        matches!(self.current_phase,
509            ComboPhase::Idle |
510            ComboPhase::HitConfirmWindow { .. }
511        )
512    }
513}
514
515// ── ComboTracker ──────────────────────────────────────────────────────────────
516
517/// High-level tracker that wraps the combo database and per-entity combo state.
518pub struct ComboTracker {
519    pub database: ComboDatabase,
520}
521
522impl ComboTracker {
523    pub fn new(database: ComboDatabase) -> Self {
524        Self { database }
525    }
526
527    /// Try to start a combo from the current input buffer state.
528    pub fn try_start(&self, state: &mut ComboState) -> Option<&Combo> {
529        let inputs = state.input_buffer.peek_sequence(8);
530        if inputs.is_empty() { return None; }
531        let combo = self.database.find_matching(&inputs, state.is_airborne, state.is_grounded)?;
532        state.start_combo(combo.id.clone());
533        Some(combo)
534    }
535
536    /// Get the current active combo (if any).
537    pub fn active_combo<'a>(&'a self, state: &ComboState) -> Option<&'a Combo> {
538        state.active_combo.as_ref().and_then(|id| self.database.get(id))
539    }
540
541    /// Get the current hit in the active combo.
542    pub fn current_hit<'a>(&'a self, state: &ComboState) -> Option<&'a ComboHit> {
543        let combo = self.active_combo(state)?;
544        let hit_idx = match &state.current_phase {
545            ComboPhase::Attacking { hit_index }           => *hit_index,
546            ComboPhase::HitConfirmWindow { hit_index, .. } => *hit_index,
547            ComboPhase::Hitstop { resume_to_index, .. }   => resume_to_index.saturating_sub(1),
548            _                                              => return None,
549        };
550        combo.hits.get(hit_idx)
551    }
552}
553
554// ── Tests ─────────────────────────────────────────────────────────────────────
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_input_buffer() {
562        let mut buf = InputBuffer::new(16, 1.0);
563        buf.push(ComboInput::LightAttack);
564        buf.push(ComboInput::LightAttack);
565        buf.push(ComboInput::HeavyAttack);
566        assert_eq!(buf.len(), 3);
567        let seq = buf.peek_sequence(3);
568        assert_eq!(seq[0], ComboInput::LightAttack);
569        assert_eq!(seq[2], ComboInput::HeavyAttack);
570    }
571
572    #[test]
573    fn test_combo_match() {
574        let combo = Combo::new("test", "Test")
575            .add_link(ComboInput::LightAttack, 0.5)
576            .add_link(ComboInput::LightAttack, 0.5)
577            .add_link(ComboInput::HeavyAttack, 0.6);
578
579        let inputs = vec![ComboInput::LightAttack, ComboInput::LightAttack, ComboInput::HeavyAttack];
580        assert!(combo.matches_sequence(&inputs));
581
582        let wrong = vec![ComboInput::LightAttack, ComboInput::HeavyAttack, ComboInput::HeavyAttack];
583        assert!(!combo.matches_sequence(&wrong));
584    }
585
586    #[test]
587    fn test_combo_database_warrior() {
588        let db = ComboDatabase::warrior_presets();
589        assert!(db.len() > 0);
590
591        let inputs = vec![
592            ComboInput::LightAttack,
593            ComboInput::LightAttack,
594            ComboInput::LightAttack,
595        ];
596        let m = db.find_matching(&inputs, false, true);
597        assert!(m.is_some());
598    }
599
600    #[test]
601    fn test_combo_state_flow() {
602        let mut state = ComboState::new();
603        state.start_combo("test".to_string());
604        assert!(state.is_in_combo());
605        assert_eq!(state.hit_count, 0);
606
607        let hit = ComboHit::new("slash", 1.0);
608        state.register_hit(50.0, &hit);
609        assert_eq!(state.hit_count, 1);
610        assert!((state.total_damage - 50.0).abs() < 0.01);
611    }
612}