Skip to main content

gizmo_scripting/
api_fighter.rs

1//! Fighter API — Lua'ya sunulan dövüş sistemi fonksiyonları
2//!
3//! Lua scriptlerinden kombo sorgulama, hitstop/hitstun uygulama ve saldırı başlatma için kullanılır.
4
5use crate::commands::{CommandQueue, ScriptCommand};
6use gizmo_core::World;
7use mlua::prelude::*;
8use std::sync::Arc;
9
10pub fn register_fighter_api(lua: &Lua, command_queue: Arc<CommandQueue>) -> Result<(), LuaError> {
11    crate::api_table::register_protected(lua, "fighter", |fighter_table| {
12
13    // Oku-Yaz tablosu
14    fighter_table.raw_set("_buffers", lua.create_table()?)?;
15    fighter_table.raw_set("_is_locked", lua.create_table()?)?;
16
17    // === SET FIGHTER MOVE ===
18    {
19        let cq = command_queue.clone();
20        fighter_table.raw_set(
21            "set_move",
22            lua.create_function(
23                move |_, (id, name, startup, active, recovery, damage): (u32, String, u32, u32, u32, f32)| {
24                    cq.push(ScriptCommand::SetFighterMove {
25                        id,
26                        name,
27                        startup,
28                        active,
29                        recovery,
30                        damage,
31                    });
32                    Ok(())
33                },
34            )?,
35        )?;
36    }
37
38    // === APPLY HITSTOP ===
39    {
40        let cq = command_queue.clone();
41        fighter_table.raw_set(
42            "apply_hitstop",
43            lua.create_function(move |_, (id, frames): (u32, u32)| {
44                cq.push(ScriptCommand::ApplyHitstop(id, frames));
45                Ok(())
46            })?,
47        )?;
48    }
49
50    // === APPLY HITSTUN ===
51    {
52        let cq = command_queue.clone();
53        fighter_table.raw_set(
54            "apply_hitstun",
55            lua.create_function(move |_, (id, frames): (u32, u32)| {
56                cq.push(ScriptCommand::ApplyHitstun(id, frames));
57                Ok(())
58            })?,
59        )?;
60    }
61
62
63    // Lua tarafında kombo kontrol eden yardımcı fonksiyon
64    lua.load(
65        r#"
66        function fighter.is_locked(id)
67            return fighter._is_locked[id] or false
68        end
69
70        function fighter.check_combo(id, combo, max_gap)
71            local buffer = fighter._buffers[id]
72            if not buffer then return false end
73
74            local combo_idx = #combo
75            if combo_idx == 0 then return false end
76
77            local gap_counter = 0
78            
79            for i = 1, #buffer do
80                local frame = buffer[i]
81                local target_input = combo[combo_idx]
82
83                if frame.just_pressed[target_input] then
84                    combo_idx = combo_idx - 1
85                    gap_counter = 0
86                    if combo_idx == 0 then
87                        return true
88                    end
89                elseif gap_counter >= max_gap then
90                    return false
91                else
92                    gap_counter = gap_counter + 1
93                end
94            end
95            
96            return false
97        end
98    "#,
99    )
100    .exec()?;
101
102        Ok(())
103    })
104}
105
106#[tracing::instrument(skip_all, name = "script_fighter_read")]
107pub fn update_fighter_read_api(lua: &Lua, world: &World) -> Result<(), LuaError> {
108    // The real table, not the global: the global is a read-only proxy so a script cannot
109    // rewrite the API (see `api_table`), and the engine's per-frame writes go behind it.
110    let fighter_table = crate::api_table::raw(lua, "fighter")?;
111
112    let buffers = lua.create_table()?;
113    let is_locked = lua.create_table()?;
114
115    let controllers = world.borrow::<gizmo_physics_core::components::FighterController>();
116    for (eid, _) in controllers.iter() {
117        if let Some(fighter) = controllers.get(eid) {
118            is_locked.set(eid, fighter.is_locked())?;
119
120            let frames_table = lua.create_table()?;
121            for (i, frame) in fighter.input_buffer.frames.iter().enumerate() {
122                let frame_table = lua.create_table()?;
123                
124                let jp_table = lua.create_table()?;
125                for k in &frame.just_pressed {
126                    jp_table.set(k.clone(), true)?;
127                }
128                
129                let p_table = lua.create_table()?;
130                for k in &frame.pressed {
131                    p_table.set(k.clone(), true)?;
132                }
133                
134                frame_table.set("just_pressed", jp_table)?;
135                frame_table.set("pressed", p_table)?;
136                
137                frames_table.set(i + 1, frame_table)?;
138            }
139            buffers.set(eid, frames_table)?;
140        }
141    }
142
143    fighter_table.raw_set("_buffers", buffers)?;
144    fighter_table.raw_set("_is_locked", is_locked)?;
145
146    Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::commands::CommandQueue;
153
154    /// Belirtilen frame indekslerinde `input` tuşunu just_pressed olarak
155    /// işaretleyen bir buffer'ı Lua'ya kuran yardımcı ve check_combo sonucu.
156    fn run_combo(setup: &str) -> bool {
157        let lua = Lua::new();
158        register_fighter_api(&lua, Arc::new(CommandQueue::new())).unwrap();
159        lua.load(setup).exec().unwrap();
160        lua.load("return fighter.check_combo(1, combo, max_gap)")
161            .eval()
162            .unwrap()
163    }
164
165    /// Regression: max_gap=2 tam olarak 2 frame boşluğa izin vermeli.
166    /// 'b' bulunduktan sonra 2 boş frame + 'a' => kabul.
167    /// 3 boş frame => ret.
168    #[test]
169    fn combo_gap_boundary_is_exact() {
170        // Buffer ileri taranır; önce combo'nun son elemanı ('b') aranır.
171        // frame1='b', frame2/3 boş, frame4='a' -> 2 boşluk, kabul edilmeli.
172        let accepted = run_combo(
173            r#"
174            combo = { "a", "b" }
175            max_gap = 2
176            local function f(k) return { just_pressed = k and { [k] = true } or {} } end
177            fighter._buffers[1] = { f("b"), f(nil), f(nil), f("a") }
178            "#,
179        );
180        assert!(accepted, "2 frame boşluk max_gap=2 için kabul edilmeli");
181
182        // frame1='b', frame2/3/4 boş, frame5='a' -> 3 boşluk, reddedilmeli.
183        let rejected = run_combo(
184            r#"
185            combo = { "a", "b" }
186            max_gap = 2
187            local function f(k) return { just_pressed = k and { [k] = true } or {} } end
188            fighter._buffers[1] = { f("b"), f(nil), f(nil), f(nil), f("a") }
189            "#,
190        );
191        assert!(
192            !rejected,
193            "3 frame boşluk max_gap=2 için REDDEDİLMELİ (off-by-one)"
194        );
195    }
196
197    /// Bitişik (boşluksuz) tam kombo tanınmalı; combo tersten (son eleman önce) taranır.
198    #[test]
199    fn adjacent_full_combo_is_recognized() {
200        let accepted = run_combo(
201            r#"
202            combo = { "a", "b", "c" }
203            max_gap = 1
204            local function f(k) return { just_pressed = { [k] = true } } end
205            -- Buffer ileri taranır, önce combo'nun sonu ("c") aranır: c, b, a
206            fighter._buffers[1] = { f("c"), f("b"), f("a") }
207            "#,
208        );
209        assert!(accepted, "bitişik c-b-a dizisi a,b,c kombosunu tamamlamalı");
210    }
211
212    /// Boş kombo listesi asla eşleşmemeli (combo_idx == 0 erken çıkış).
213    #[test]
214    fn empty_combo_never_matches() {
215        let matched = run_combo(
216            r#"
217            combo = {}
218            max_gap = 5
219            local function f(k) return { just_pressed = { [k] = true } } end
220            fighter._buffers[1] = { f("a"), f("b") }
221            "#,
222        );
223        assert!(!matched, "boş kombo false dönmeli");
224    }
225
226    /// Entity için buffer yoksa check_combo güvenle false dönmeli (nil buffer koruması).
227    #[test]
228    fn missing_buffer_returns_false() {
229        let matched = run_combo(
230            r#"
231            combo = { "a" }
232            max_gap = 5
233            -- fighter._buffers[1] hiç ayarlanmadı
234            "#,
235        );
236        assert!(!matched, "buffer yoksa false dönmeli");
237    }
238
239    /// Kombo tam tamamlanmazsa (yalnız son eleman var, ilki yok) false dönmeli.
240    #[test]
241    fn partially_matched_combo_is_rejected() {
242        let matched = run_combo(
243            r#"
244            combo = { "a", "b" }
245            max_gap = 5
246            local function f(k) return { just_pressed = { [k] = true } } end
247            -- Sadece "b" var, "a" hiç basılmadı → kombo tamamlanmaz.
248            fighter._buffers[1] = { f("b"), f("x"), f("y") }
249            "#,
250        );
251        assert!(!matched, "eksik kombo tamamlanmamış sayılmalı");
252    }
253
254    /// is_locked: _is_locked tablosunda giriş yoksa false, true ise true.
255    #[test]
256    fn is_locked_reads_table_with_false_default() {
257        let lua = Lua::new();
258        register_fighter_api(&lua, Arc::new(CommandQueue::new())).unwrap();
259        lua.load(
260            r#"
261            assert(fighter.is_locked(1) == false, "giriş yoksa varsayılan false")
262            fighter._is_locked[1] = true
263            assert(fighter.is_locked(1) == true, "true set edilince true")
264            "#,
265        )
266        .exec()
267        .unwrap();
268    }
269
270    /// set_move / apply_hitstop / apply_hitstun doğru komutları (frame verileri dahil) kuyruğa yazmalı.
271    #[test]
272    fn fighter_write_calls_push_expected_commands() {
273        let lua = Lua::new();
274        let cq = Arc::new(CommandQueue::new());
275        register_fighter_api(&lua, cq.clone()).unwrap();
276
277        lua.load(
278            r#"
279            fighter.set_move(1, "jab", 3, 2, 8, 5.5)
280            fighter.apply_hitstop(1, 6)
281            fighter.apply_hitstun(2, 20)
282            "#,
283        )
284        .exec()
285        .unwrap();
286
287        let cmds = cq.drain();
288        assert_eq!(cmds.len(), 3);
289        match &cmds[0] {
290            ScriptCommand::SetFighterMove { id, name, startup, active, recovery, damage } => {
291                assert_eq!(*id, 1);
292                assert_eq!(name, "jab");
293                assert_eq!((*startup, *active, *recovery), (3, 2, 8));
294                assert!((damage - 5.5).abs() < 1e-6);
295            }
296            other => panic!("beklenen SetFighterMove, gelen {other:?}"),
297        }
298        assert!(matches!(cmds[1], ScriptCommand::ApplyHitstop(1, 6)));
299        assert!(matches!(cmds[2], ScriptCommand::ApplyHitstun(2, 20)));
300    }
301}