1use std::collections::VecDeque;
4
5#[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#[derive(Debug, Clone)]
52pub struct BufferedInput {
53 pub input: ComboInput,
54 pub timestamp: f64,
55 pub consumed: bool,
56}
57
58#[derive(Debug, Clone)]
60pub struct InputBuffer {
61 inputs: VecDeque<BufferedInput>,
62 max_size: usize,
63 buffer_window: f64, 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 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#[derive(Debug, Clone)]
132pub struct ComboLink {
133 pub input: ComboInput,
134 pub time_window: f32,
136 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#[derive(Debug, Clone)]
151pub struct ComboHit {
152 pub animation_id: String,
153 pub damage_multiplier: f32,
154 pub hitstop_duration: f32, pub launch: bool,
156 pub knockback_force: f32,
157 pub can_cancel_into: Vec<String>, pub hit_confirm_start: f32, 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#[derive(Debug, Clone)]
219pub struct Combo {
220 pub id: String,
221 pub name: String,
222 pub links: Vec<ComboLink>, pub hits: Vec<ComboHit>, pub ender: Option<ComboEnder>,
225 pub requires_airborne: bool,
226 pub requires_grounded: bool,
227 pub priority: u32, }
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 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#[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 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 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#[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>, pub current_phase: ComboPhase,
402 pub hit_count: u32,
403 pub total_damage: f32,
404 pub combo_timer: f32, pub combo_timeout: f32, 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 if self.active_combo.is_some() && self.combo_timer > self.combo_timeout {
432 self.reset();
433 return;
434 }
435
436 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
515pub struct ComboTracker {
519 pub database: ComboDatabase,
520}
521
522impl ComboTracker {
523 pub fn new(database: ComboDatabase) -> Self {
524 Self { database }
525 }
526
527 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 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 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#[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}