Skip to main content

jugar_web/
platform.rs

1//! Web platform integration for the Jugar game engine.
2//!
3//! This module provides the main `WebPlatform` struct that bridges the Jugar engine
4//! to browser APIs via wasm-bindgen. All game logic runs in Rust; JavaScript only
5//! handles event forwarding and Canvas2D rendering.
6
7use glam::Vec2;
8use serde::{Deserialize, Serialize};
9use wasm_bindgen::prelude::*;
10
11use crate::ai::PongAI;
12use crate::audio::{AudioEvent, ProceduralAudio};
13use crate::demo::{DemoState, GameMode, SpeedMultiplier};
14use crate::input::{process_input_events, InputTranslationError};
15use crate::juice::JuiceEffects;
16use crate::render::{Canvas2DCommand, Color, RenderFrame, TextAlign, TextBaseline};
17use crate::time::FrameTimer;
18use crate::trace::{GameTracer, TracerConfig};
19use jugar_input::{InputState, MouseButton};
20
21/// A clickable button rectangle.
22#[derive(Debug, Clone, Copy, Default)]
23struct ButtonRect {
24    x: f32,
25    y: f32,
26    width: f32,
27    height: f32,
28}
29
30impl ButtonRect {
31    const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
32        Self {
33            x,
34            y,
35            width,
36            height,
37        }
38    }
39
40    /// Returns true if the point is inside the button.
41    fn contains(&self, px: f32, py: f32) -> bool {
42        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
43    }
44}
45
46/// HUD button regions for click detection.
47#[derive(Debug, Clone, Default)]
48struct HudButtons {
49    /// Mode buttons: Demo, 1P, 2P
50    mode_demo: ButtonRect,
51    mode_1p: ButtonRect,
52    mode_2p: ButtonRect,
53    /// Speed buttons: 1x, 5x, 10x, 50x, 100x, 1000x
54    speed_1x: ButtonRect,
55    speed_5x: ButtonRect,
56    speed_10x: ButtonRect,
57    speed_50x: ButtonRect,
58    speed_100x: ButtonRect,
59    speed_1000x: ButtonRect,
60    /// AI difficulty buttons
61    ai_decrease: ButtonRect,
62    ai_increase: ButtonRect,
63    /// Download button
64    download: ButtonRect,
65    /// Model info toggle button
66    model_info: ButtonRect,
67    /// Sound toggle button
68    sound_toggle: ButtonRect,
69}
70
71impl HudButtons {
72    /// Calculate button width from text label.
73    /// Uses text length * char_width + padding on both sides.
74    #[inline]
75    fn button_width(text: &str, char_width: f32, padding: f32) -> f32 {
76        (text.len() as f32).mul_add(char_width, padding * 2.0)
77    }
78
79    /// Calculate initial button positions based on canvas dimensions.
80    /// This ensures buttons are clickable from the first frame.
81    #[must_use]
82    fn calculate(width: f32, height: f32) -> Self {
83        let hud_y = 10.0;
84        let button_height = 28.0;
85        let button_padding = 8.0;
86        let char_width = 8.0;
87
88        // Mode buttons (top-left)
89        let mode_x = 10.0;
90        let demo_width = Self::button_width("Demo", char_width, button_padding);
91        let p1_width = Self::button_width("1P", char_width, button_padding);
92        let p2_width = Self::button_width("2P", char_width, button_padding);
93
94        let mode_demo = ButtonRect::new(mode_x, hud_y, demo_width, button_height);
95        let mode_1p = ButtonRect::new(mode_x + demo_width + 5.0, hud_y, p1_width, button_height);
96        let mode_2p = ButtonRect::new(
97            mode_x + demo_width + 5.0 + p1_width + 5.0,
98            hud_y,
99            p2_width,
100            button_height,
101        );
102
103        // Speed buttons (top-right)
104        let w1x = Self::button_width("1x", char_width, button_padding);
105        let w5x = Self::button_width("5x", char_width, button_padding);
106        let w10x = Self::button_width("10x", char_width, button_padding);
107        let w50x = Self::button_width("50x", char_width, button_padding);
108        let w100x = Self::button_width("100x", char_width, button_padding);
109        let w1000x = Self::button_width("1000x", char_width, button_padding);
110        let total_width = w1x + w5x + w10x + w50x + w100x + w1000x + 25.0;
111
112        let mut speed_x = width - total_width - 10.0;
113        let speed_1x = ButtonRect::new(speed_x, hud_y, w1x, button_height);
114        speed_x += w1x + 5.0;
115        let speed_5x = ButtonRect::new(speed_x, hud_y, w5x, button_height);
116        speed_x += w5x + 5.0;
117        let speed_10x = ButtonRect::new(speed_x, hud_y, w10x, button_height);
118        speed_x += w10x + 5.0;
119        let speed_50x = ButtonRect::new(speed_x, hud_y, w50x, button_height);
120        speed_x += w50x + 5.0;
121        let speed_100x = ButtonRect::new(speed_x, hud_y, w100x, button_height);
122        speed_x += w100x + 5.0;
123        let speed_1000x = ButtonRect::new(speed_x, hud_y, w1000x, button_height);
124
125        // AI difficulty buttons (below mode buttons)
126        // These positions must match render_hud() logic exactly
127        let ai_y = hud_y + button_height + 15.0;
128        let ai_btn_size = 20.0;
129        let ai_btn_y = ai_y - 2.0;
130        let bar_x = 40.0;
131        let bar_width = 100.0;
132        let ai_btn_x = bar_x + bar_width + 100.0; // 240.0 - after "X/9 Nightmare" text
133        let ai_plus_x = ai_btn_x + ai_btn_size + 5.0; // 265.0
134        let ai_decrease = ButtonRect::new(ai_btn_x, ai_btn_y, ai_btn_size, ai_btn_size);
135        let ai_increase = ButtonRect::new(ai_plus_x, ai_btn_y, ai_btn_size, ai_btn_size);
136
137        // Download button (bottom-left)
138        let download_y = height - 45.0;
139        let download_width = Self::button_width("Download .apr", char_width, button_padding);
140        let download = ButtonRect::new(10.0, download_y, download_width, button_height);
141
142        // Model info button (next to download)
143        let info_width = Self::button_width("Info", char_width, button_padding);
144        let model_info = ButtonRect::new(
145            10.0 + download_width + 5.0,
146            download_y,
147            info_width,
148            button_height,
149        );
150
151        // Sound toggle button (next to model info, after [I] hint)
152        let sound_width = Self::button_width("Sound", char_width, button_padding);
153        let info_x = 10.0 + download_width + 5.0;
154        let sound_toggle = ButtonRect::new(
155            info_x + info_width + 25.0, // 25.0 accounts for "[I]" hint spacing
156            download_y,
157            sound_width,
158            button_height,
159        );
160
161        Self {
162            mode_demo,
163            mode_1p,
164            mode_2p,
165            speed_1x,
166            speed_5x,
167            speed_10x,
168            speed_50x,
169            speed_100x,
170            speed_1000x,
171            ai_decrease,
172            ai_increase,
173            download,
174            model_info,
175            sound_toggle,
176        }
177    }
178}
179
180/// Web platform configuration.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct WebConfig {
183    /// Canvas width in pixels
184    #[serde(default = "default_width")]
185    pub width: u32,
186    /// Canvas height in pixels
187    #[serde(default = "default_height")]
188    pub height: u32,
189    /// Target frames per second (for fixed timestep)
190    #[serde(default = "default_fps")]
191    pub target_fps: u32,
192    /// Enable debug rendering
193    #[serde(default)]
194    pub debug: bool,
195    /// Enable AI opponent (replaces Player 2)
196    #[serde(default = "default_ai_enabled")]
197    pub ai_enabled: bool,
198}
199
200const fn default_ai_enabled() -> bool {
201    true // AI enabled by default for single-player experience
202}
203
204const fn default_width() -> u32 {
205    800
206}
207
208const fn default_height() -> u32 {
209    600
210}
211
212const fn default_fps() -> u32 {
213    60
214}
215
216impl Default for WebConfig {
217    fn default() -> Self {
218        Self {
219            width: 800,
220            height: 600,
221            target_fps: 60,
222            debug: false,
223            ai_enabled: true,
224        }
225    }
226}
227
228impl WebConfig {
229    /// Creates a new web config with specified dimensions.
230    #[must_use]
231    pub const fn new(width: u32, height: u32) -> Self {
232        Self {
233            width,
234            height,
235            target_fps: 60,
236            debug: false,
237            ai_enabled: true,
238        }
239    }
240
241    /// Parses configuration from JSON string.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the JSON is invalid.
246    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
247        serde_json::from_str(json)
248    }
249
250    /// Serializes configuration to JSON string.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if serialization fails.
255    pub fn to_json(&self) -> Result<String, serde_json::Error> {
256        serde_json::to_string(self)
257    }
258}
259
260/// Error type for web platform operations.
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub enum WebPlatformError {
263    /// Invalid configuration
264    InvalidConfig(String),
265    /// Input processing error
266    InputError(String),
267    /// Render error
268    RenderError(String),
269}
270
271impl core::fmt::Display for WebPlatformError {
272    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
273        match self {
274            Self::InvalidConfig(msg) => write!(f, "Invalid config: {msg}"),
275            Self::InputError(msg) => write!(f, "Input error: {msg}"),
276            Self::RenderError(msg) => write!(f, "Render error: {msg}"),
277        }
278    }
279}
280
281impl core::error::Error for WebPlatformError {}
282
283impl From<InputTranslationError> for WebPlatformError {
284    fn from(err: InputTranslationError) -> Self {
285        Self::InputError(err.to_string())
286    }
287}
288
289/// An action to be performed by JavaScript.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291#[serde(tag = "type")]
292pub enum JsAction {
293    /// Trigger download of the AI model as .apr file
294    DownloadAiModel,
295    /// Open a URL in a new tab
296    OpenUrl {
297        /// The URL to open
298        url: String,
299    },
300    /// Request fullscreen mode (for ultra-wide monitors)
301    EnterFullscreen,
302    /// Exit fullscreen mode
303    ExitFullscreen,
304}
305
306/// Frame output returned to JavaScript.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct FrameOutput {
309    /// Render commands to execute
310    pub commands: Vec<Canvas2DCommand>,
311    /// Audio events to play via Web Audio API
312    #[serde(skip_serializing_if = "Vec::is_empty", default)]
313    pub audio_events: Vec<AudioEvent>,
314    /// JavaScript actions to perform
315    #[serde(skip_serializing_if = "Vec::is_empty", default)]
316    pub actions: Vec<JsAction>,
317    /// Debug information (only present if debug mode enabled)
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub debug_info: Option<DebugInfo>,
320}
321
322/// Debug information for development.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct DebugInfo {
325    /// Delta time in milliseconds
326    pub dt_ms: f64,
327    /// Current FPS
328    pub fps: f64,
329    /// Frame count
330    pub frame_count: u64,
331    /// Input state summary
332    pub input_summary: String,
333    /// Current game mode (Demo, SinglePlayer, TwoPlayer)
334    pub game_mode: String,
335    /// Current speed multiplier (1, 5, 10, 50, 100, 1000)
336    pub speed_multiplier: u32,
337    /// Left paddle Y position (center)
338    pub left_paddle_y: f32,
339    /// Right paddle Y position (center)
340    pub right_paddle_y: f32,
341    /// Ball X position
342    pub ball_x: f32,
343    /// Ball Y position
344    pub ball_y: f32,
345    /// Trace buffer usage (frames in buffer / capacity)
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub trace_buffer_usage: Option<String>,
348    /// Total input events recorded
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub trace_inputs: Option<u64>,
351    /// Frames dropped from trace buffer
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub trace_dropped: Option<u64>,
354}
355
356/// Trait for game implementations that can run on the web platform.
357///
358/// Games implement this trait to integrate with the `WebPlatform`.
359pub trait WebGame: Send {
360    /// Called each frame with input and delta time.
361    ///
362    /// # Arguments
363    ///
364    /// * `input` - Current input state
365    /// * `dt` - Delta time in seconds
366    fn update(&mut self, input: &InputState, dt: f64);
367
368    /// Called to generate render commands for the current frame.
369    /// Mutable because HUD button positions are updated during render.
370    ///
371    /// # Arguments
372    ///
373    /// * `frame` - Render frame to push commands to
374    fn render(&mut self, frame: &mut RenderFrame);
375
376    /// Called when the canvas is resized.
377    ///
378    /// # Arguments
379    ///
380    /// * `width` - New width in pixels
381    /// * `height` - New height in pixels
382    fn resize(&mut self, width: u32, height: u32);
383}
384
385/// Game state for menu/pause functionality.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
387pub enum GameState {
388    /// Waiting at menu for player to start
389    #[default]
390    Menu,
391    /// Game is actively playing
392    Playing,
393    /// Game is paused
394    Paused,
395    /// Game over (one player reached winning score)
396    GameOver,
397}
398
399/// A simple Pong game implementation for testing.
400#[derive(Debug, Clone)]
401#[allow(clippy::struct_excessive_bools)] // Key state tracking requires bools
402pub struct PongGame {
403    /// Canvas width
404    width: f32,
405    /// Canvas height
406    height: f32,
407    /// Left paddle Y position
408    left_paddle_y: f32,
409    /// Right paddle Y position
410    right_paddle_y: f32,
411    /// Ball X position
412    ball_x: f32,
413    /// Ball Y position
414    ball_y: f32,
415    /// Ball X velocity
416    ball_vx: f32,
417    /// Ball Y velocity
418    ball_vy: f32,
419    /// Left player score
420    left_score: u32,
421    /// Right player score
422    right_score: u32,
423    /// Paddle height
424    paddle_height: f32,
425    /// Paddle width
426    paddle_width: f32,
427    /// Ball radius
428    ball_radius: f32,
429    /// Paddle speed
430    paddle_speed: f32,
431    /// AI opponent (if enabled)
432    ai: Option<PongAI>,
433    /// Juice effects (screen shake, trails, etc.)
434    juice: JuiceEffects,
435    /// Previous ball Y for wall bounce detection
436    prev_ball_y: f32,
437    /// Current game state
438    state: GameState,
439    /// Winning score threshold
440    winning_score: u32,
441    /// Track if Space was pressed last frame (for edge detection)
442    space_was_pressed: bool,
443    /// Track if Escape was pressed last frame (for edge detection)
444    escape_was_pressed: bool,
445    /// Current rally count (consecutive hits without scoring)
446    rally_count: u32,
447    /// High score (persisted across games in session)
448    high_score: u32,
449    /// Background animation time accumulator
450    bg_time: f32,
451    /// Procedural audio generator
452    audio: ProceduralAudio,
453    /// Current speed multiplier for physics
454    speed_multiplier: SpeedMultiplier,
455    /// Current game mode (Demo/1P/2P)
456    game_mode: GameMode,
457    /// Demo mode state (idle timer, difficulty cycling)
458    #[allow(dead_code)] // Reserved for auto-engage and difficulty cycling features
459    demo_state: DemoState,
460    /// Second AI for left paddle (used in Demo mode)
461    left_ai: Option<PongAI>,
462    /// Track key states for edge detection
463    key_1_was_pressed: bool,
464    key_2_was_pressed: bool,
465    key_3_was_pressed: bool,
466    key_4_was_pressed: bool,
467    key_5_was_pressed: bool,
468    key_6_was_pressed: bool,
469    key_m_was_pressed: bool,
470    key_d_was_pressed: bool,
471    #[allow(dead_code)] // Reserved for AI difficulty adjustment via +/- keys
472    key_plus_was_pressed: bool,
473    #[allow(dead_code)] // Reserved for AI difficulty adjustment via +/- keys
474    key_minus_was_pressed: bool,
475    /// Mouse button was pressed last frame (for edge detection)
476    mouse_was_pressed: bool,
477    /// HUD button hit regions (updated during render)
478    hud_buttons: HudButtons,
479    /// Flag to trigger .apr download action (consumed after frame)
480    download_requested: bool,
481    /// Whether to show the model info panel (toggle with I key or Info button)
482    show_model_info: bool,
483    /// Track if I key was pressed last frame (for edge detection)
484    key_i_was_pressed: bool,
485    /// Whether sound is enabled (toggle with Sound button)
486    sound_enabled: bool,
487    /// Track if F key was pressed last frame (for edge detection)
488    key_f_was_pressed: bool,
489    /// Flag to request fullscreen (consumed after frame)
490    fullscreen_requested: bool,
491    /// Track current fullscreen state (to toggle)
492    is_fullscreen: bool,
493}
494
495impl Default for PongGame {
496    fn default() -> Self {
497        Self::new(800.0, 600.0, true)
498    }
499}
500
501impl PongGame {
502    /// Creates a new Pong game with the given dimensions.
503    #[must_use]
504    pub fn new(width: f32, height: f32, ai_enabled: bool) -> Self {
505        let ai = if ai_enabled {
506            Some(PongAI::default())
507        } else {
508            None
509        };
510
511        Self {
512            width,
513            height,
514            left_paddle_y: height / 2.0,
515            right_paddle_y: height / 2.0,
516            ball_x: width / 2.0,
517            ball_y: height / 2.0,
518            ball_vx: 200.0,
519            ball_vy: 150.0,
520            left_score: 0,
521            right_score: 0,
522            paddle_height: 100.0,
523            paddle_width: 15.0,
524            ball_radius: 10.0,
525            paddle_speed: 400.0,
526            ai,
527            juice: JuiceEffects::new(),
528            prev_ball_y: height / 2.0,
529            state: GameState::Playing, // Start playing immediately in Demo mode (attract)
530            winning_score: 11,
531            space_was_pressed: false,
532            escape_was_pressed: false,
533            rally_count: 0,
534            high_score: 0,
535            bg_time: 0.0,
536            audio: ProceduralAudio::new(),
537            speed_multiplier: SpeedMultiplier::default(),
538            game_mode: GameMode::default(),
539            demo_state: DemoState::default(),
540            left_ai: Some(PongAI::with_difficulty(6)), // Left AI for demo mode (Hard)
541            key_1_was_pressed: false,
542            key_2_was_pressed: false,
543            key_3_was_pressed: false,
544            key_4_was_pressed: false,
545            key_5_was_pressed: false,
546            key_6_was_pressed: false,
547            key_m_was_pressed: false,
548            key_d_was_pressed: false,
549            key_plus_was_pressed: false,
550            key_minus_was_pressed: false,
551            mouse_was_pressed: false,
552            hud_buttons: HudButtons::calculate(width, height),
553            download_requested: false,
554            show_model_info: false,
555            key_i_was_pressed: false,
556            sound_enabled: true, // Sound on by default
557            key_f_was_pressed: false,
558            fullscreen_requested: false,
559            is_fullscreen: false,
560        }
561    }
562
563    /// Returns the current speed multiplier.
564    #[must_use]
565    pub const fn speed_multiplier(&self) -> SpeedMultiplier {
566        self.speed_multiplier
567    }
568
569    /// Sets the speed multiplier.
570    #[allow(clippy::missing_const_for_fn)] // Not const due to mutable reference
571    pub fn set_speed_multiplier(&mut self, speed: SpeedMultiplier) {
572        self.speed_multiplier = speed;
573    }
574
575    /// Returns the current game mode.
576    #[must_use]
577    pub const fn game_mode(&self) -> GameMode {
578        self.game_mode
579    }
580
581    /// Sets the game mode.
582    pub fn set_game_mode(&mut self, mode: GameMode) {
583        self.game_mode = mode;
584        self.reset_game();
585    }
586
587    /// Resets the game to initial state.
588    pub fn reset_game(&mut self) {
589        self.left_paddle_y = self.height / 2.0;
590        self.right_paddle_y = self.height / 2.0;
591        self.ball_x = self.width / 2.0;
592        self.ball_y = self.height / 2.0;
593        self.ball_vx = 200.0;
594        self.ball_vy = 150.0;
595        self.left_score = 0;
596        self.right_score = 0;
597        self.prev_ball_y = self.height / 2.0;
598        self.rally_count = 0;
599        // High score persists across games - don't reset it
600        self.juice.reset();
601        if let Some(ref mut ai) = self.ai {
602            ai.reset();
603        }
604    }
605
606    /// Returns the current rally count.
607    #[must_use]
608    pub const fn rally_count(&self) -> u32 {
609        self.rally_count
610    }
611
612    /// Returns the high score.
613    #[must_use]
614    pub const fn high_score(&self) -> u32 {
615        self.high_score
616    }
617
618    /// Takes all pending audio events (returns empty if sound is disabled).
619    pub fn take_audio_events(&mut self) -> Vec<AudioEvent> {
620        if self.sound_enabled {
621            self.audio.take_events()
622        } else {
623            // Clear events but don't return them
624            drop(self.audio.take_events());
625            Vec::new()
626        }
627    }
628
629    /// Exports the AI model as JSON for download.
630    #[must_use]
631    pub fn export_ai_model(&self) -> String {
632        self.ai.as_ref().map_or_else(
633            || crate::ai::PongAIModel::default().to_json(),
634            crate::ai::PongAI::export_model,
635        )
636    }
637
638    /// Returns AI info as JSON.
639    #[must_use]
640    pub fn ai_info(&self) -> String {
641        self.ai.as_ref().map_or_else(
642            || r#"{"enabled": false}"#.to_string(),
643            crate::ai::PongAI::model_info_json,
644        )
645    }
646
647    /// Sets the AI difficulty level (0-9).
648    pub fn set_ai_difficulty(&mut self, level: u8) {
649        if let Some(ref mut ai) = self.ai {
650            ai.set_difficulty(level);
651        }
652    }
653
654    /// Gets the current AI difficulty level.
655    #[must_use]
656    pub fn ai_difficulty(&self) -> u8 {
657        self.ai.as_ref().map_or(5, crate::ai::PongAI::difficulty)
658    }
659
660    /// Returns the current game state.
661    #[must_use]
662    pub const fn state(&self) -> GameState {
663        self.state
664    }
665
666    /// Sets the game state (for testing).
667    #[cfg(test)]
668    #[allow(clippy::missing_const_for_fn)] // const fn with mutable ref not stable
669    pub fn set_state(&mut self, state: GameState) {
670        self.state = state;
671    }
672
673    /// Starts the game (transitions from Menu to Playing).
674    pub fn start(&mut self) {
675        if self.state == GameState::Menu {
676            self.reset_game();
677            self.state = GameState::Playing;
678        }
679    }
680
681    /// Resets the ball to the center.
682    fn reset_ball(&mut self) {
683        self.ball_x = self.width / 2.0;
684        self.ball_y = self.height / 2.0;
685        // Reverse direction towards the player who lost
686        self.ball_vx = -self.ball_vx.signum() * 200.0;
687        self.ball_vy = if fastrand::bool() { 150.0 } else { -150.0 };
688    }
689
690    /// Returns the left score.
691    #[must_use]
692    pub const fn left_score(&self) -> u32 {
693        self.left_score
694    }
695
696    /// Returns the right score.
697    #[must_use]
698    pub const fn right_score(&self) -> u32 {
699        self.right_score
700    }
701
702    /// Returns the ball position.
703    #[must_use]
704    pub const fn ball_position(&self) -> (f32, f32) {
705        (self.ball_x, self.ball_y)
706    }
707
708    // ===== Test helper methods =====
709
710    /// Returns the ball X position.
711    #[must_use]
712    pub const fn ball_x(&self) -> f32 {
713        self.ball_x
714    }
715
716    /// Returns the ball Y position.
717    #[must_use]
718    pub const fn ball_y(&self) -> f32 {
719        self.ball_y
720    }
721
722    /// Returns the ball X velocity.
723    #[must_use]
724    pub const fn ball_vx(&self) -> f32 {
725        self.ball_vx
726    }
727
728    /// Returns the ball Y velocity.
729    #[must_use]
730    pub const fn ball_vy(&self) -> f32 {
731        self.ball_vy
732    }
733
734    /// Returns the left paddle Y position.
735    #[must_use]
736    pub const fn left_paddle_y(&self) -> f32 {
737        self.left_paddle_y
738    }
739
740    /// Returns the right paddle Y position.
741    #[must_use]
742    pub const fn right_paddle_y(&self) -> f32 {
743        self.right_paddle_y
744    }
745
746    /// Returns the paddle height.
747    #[must_use]
748    pub const fn paddle_height(&self) -> f32 {
749        self.paddle_height
750    }
751
752    /// Returns the paddle speed.
753    #[must_use]
754    pub const fn paddle_speed(&self) -> f32 {
755        self.paddle_speed
756    }
757
758    /// Returns whether fullscreen is active.
759    #[must_use]
760    pub const fn is_fullscreen(&self) -> bool {
761        self.is_fullscreen
762    }
763
764    /// Sets ball position (for testing).
765    #[cfg(test)]
766    pub fn set_ball_position(&mut self, x: f32, y: f32) {
767        self.ball_x = x;
768        self.ball_y = y;
769    }
770
771    /// Sets ball velocity (for testing).
772    #[cfg(test)]
773    pub fn set_ball_velocity(&mut self, vx: f32, vy: f32) {
774        self.ball_vx = vx;
775        self.ball_vy = vy;
776    }
777
778    /// Sets left paddle Y position (for testing).
779    #[cfg(test)]
780    pub fn set_left_paddle_y(&mut self, y: f32) {
781        self.left_paddle_y = y;
782    }
783
784    /// Sets right paddle Y position (for testing).
785    #[cfg(test)]
786    pub fn set_right_paddle_y(&mut self, y: f32) {
787        self.right_paddle_y = y;
788    }
789
790    /// Sets left score (for testing).
791    #[cfg(test)]
792    pub fn set_left_score(&mut self, score: u32) {
793        self.left_score = score;
794    }
795
796    /// Sets right score (for testing).
797    #[cfg(test)]
798    pub fn set_right_score(&mut self, score: u32) {
799        self.right_score = score;
800    }
801
802    /// Sets rally count (for testing).
803    #[cfg(test)]
804    pub fn set_rally_count(&mut self, count: u32) {
805        self.rally_count = count;
806    }
807
808    /// Resets the game (for testing).
809    #[cfg(test)]
810    pub fn reset(&mut self) {
811        self.reset_game();
812    }
813
814    /// Enables or disables sound (for testing).
815    #[cfg(test)]
816    pub fn enable_sound(&mut self, enabled: bool) {
817        self.sound_enabled = enabled;
818    }
819}
820
821impl WebGame for PongGame {
822    #[allow(clippy::too_many_lines)] // Game update logic is inherently complex
823    fn update(&mut self, input: &InputState, dt: f64) {
824        let base_dt = dt as f32;
825
826        // Handle keyboard shortcuts for speed (1-6) with edge detection
827        let key_1 = input.is_key_pressed(jugar_input::KeyCode::Number(1));
828        let key_2 = input.is_key_pressed(jugar_input::KeyCode::Number(2));
829        let key_3 = input.is_key_pressed(jugar_input::KeyCode::Number(3));
830        let key_4 = input.is_key_pressed(jugar_input::KeyCode::Number(4));
831        let key_5 = input.is_key_pressed(jugar_input::KeyCode::Number(5));
832        let key_6 = input.is_key_pressed(jugar_input::KeyCode::Number(6));
833        let key_m = input.is_key_pressed(jugar_input::KeyCode::Letter('M'));
834        let key_d = input.is_key_pressed(jugar_input::KeyCode::Letter('D'));
835        let key_i = input.is_key_pressed(jugar_input::KeyCode::Letter('I'));
836        let key_f = input.is_key_pressed(jugar_input::KeyCode::Letter('F'));
837        let key_f11 = input.is_key_pressed(jugar_input::KeyCode::Function(11));
838
839        if key_1 && !self.key_1_was_pressed {
840            self.speed_multiplier = SpeedMultiplier::Normal;
841        }
842        if key_2 && !self.key_2_was_pressed {
843            self.speed_multiplier = SpeedMultiplier::Fast5x;
844        }
845        if key_3 && !self.key_3_was_pressed {
846            self.speed_multiplier = SpeedMultiplier::Fast10x;
847        }
848        if key_4 && !self.key_4_was_pressed {
849            self.speed_multiplier = SpeedMultiplier::Fast50x;
850        }
851        if key_5 && !self.key_5_was_pressed {
852            self.speed_multiplier = SpeedMultiplier::Fast100x;
853        }
854        if key_6 && !self.key_6_was_pressed {
855            self.speed_multiplier = SpeedMultiplier::Fast1000x;
856        }
857        if key_m && !self.key_m_was_pressed {
858            self.game_mode = self.game_mode.next();
859        }
860        // D key toggles between Demo and SinglePlayer
861        if key_d && !self.key_d_was_pressed {
862            self.game_mode = if self.game_mode == GameMode::Demo {
863                GameMode::SinglePlayer
864            } else {
865                GameMode::Demo
866            };
867        }
868        // I key toggles model info panel
869        if key_i && !self.key_i_was_pressed {
870            self.show_model_info = !self.show_model_info;
871        }
872        // F or F11 toggles fullscreen (logic in Rust, action executed by JS)
873        let fullscreen_key = (key_f || key_f11) && !self.key_f_was_pressed;
874        if fullscreen_key {
875            self.fullscreen_requested = true;
876            self.is_fullscreen = !self.is_fullscreen;
877        }
878
879        self.key_1_was_pressed = key_1;
880        self.key_2_was_pressed = key_2;
881        self.key_3_was_pressed = key_3;
882        self.key_4_was_pressed = key_4;
883        self.key_5_was_pressed = key_5;
884        self.key_6_was_pressed = key_6;
885        self.key_m_was_pressed = key_m;
886        self.key_d_was_pressed = key_d;
887        self.key_i_was_pressed = key_i;
888        self.key_f_was_pressed = key_f || key_f11;
889
890        // Handle mouse click on HUD buttons
891        let mouse_pressed = input.mouse_button(MouseButton::Left).is_down();
892        let mouse_just_clicked = mouse_pressed && !self.mouse_was_pressed;
893        self.mouse_was_pressed = mouse_pressed;
894
895        if mouse_just_clicked {
896            let pos = input.mouse_position;
897            self.handle_hud_click(pos.x, pos.y);
898        }
899
900        // Handle state transitions first
901        let space_pressed = input.is_key_pressed(jugar_input::KeyCode::Space);
902        let escape_pressed = input.is_key_pressed(jugar_input::KeyCode::Escape);
903
904        // Edge detection for Space key
905        let space_just_pressed = space_pressed && !self.space_was_pressed;
906        let escape_just_pressed = escape_pressed && !self.escape_was_pressed;
907
908        self.space_was_pressed = space_pressed;
909        self.escape_was_pressed = escape_pressed;
910
911        match self.state {
912            GameState::Menu => {
913                if space_just_pressed {
914                    self.reset_game();
915                    self.state = GameState::Playing;
916                    self.audio.on_game_start();
917                }
918                return; // Don't update game logic in menu
919            }
920            GameState::Playing => {
921                if escape_just_pressed {
922                    self.state = GameState::Paused;
923                    return;
924                }
925            }
926            GameState::Paused => {
927                if space_just_pressed || escape_just_pressed {
928                    self.state = GameState::Playing;
929                }
930                return; // Don't update game logic when paused
931            }
932            GameState::GameOver => {
933                if space_just_pressed {
934                    self.reset_game();
935                    self.state = GameState::Playing;
936                    self.audio.on_game_start();
937                }
938                return; // Don't update game logic at game over
939            }
940        }
941
942        // Game is Playing - run game logic with speed multiplier
943        // Speed multiplier runs physics multiple times per frame
944        let speed_mult = self.speed_multiplier.value();
945        let dt = base_dt * speed_mult as f32; // Scale dt for faster simulation
946        let half_paddle = self.paddle_height / 2.0;
947
948        // Store previous ball Y for wall bounce detection
949        self.prev_ball_y = self.ball_y;
950
951        // Left paddle controls - based on game mode
952        // In Demo mode: AI controls left paddle
953        // In SinglePlayer mode: AI controls left paddle
954        // In TwoPlayer mode: P2 human controls left paddle (W/S)
955        if self.game_mode.left_is_ai() {
956            // AI controls left paddle (Demo and SinglePlayer modes)
957            if let Some(ref mut left_ai) = self.left_ai {
958                let ai_velocity = left_ai.update(
959                    self.ball_x,
960                    self.ball_y,
961                    -self.ball_vx, // Invert for left side perspective
962                    self.ball_vy,
963                    self.left_paddle_y,
964                    self.paddle_height,
965                    self.width,
966                    self.height,
967                    dt,
968                );
969                self.left_paddle_y += ai_velocity * dt;
970            }
971        } else {
972            // P2 human controls left paddle (W/S keys) - TwoPlayer mode only
973            if input.is_key_pressed(jugar_input::KeyCode::Letter('W')) {
974                self.left_paddle_y -= self.paddle_speed * dt;
975            }
976            if input.is_key_pressed(jugar_input::KeyCode::Letter('S')) {
977                self.left_paddle_y += self.paddle_speed * dt;
978            }
979        }
980
981        // Right paddle controls - based on game mode
982        // In Demo mode: AI controls right paddle
983        // In SinglePlayer mode: P1 human controls right paddle (Arrow keys)
984        // In TwoPlayer mode: P1 human controls right paddle (Arrow keys)
985        if self.game_mode.right_is_ai() {
986            // AI controls the right paddle (Demo mode only)
987            if let Some(ref mut ai) = self.ai {
988                let ai_velocity = ai.update(
989                    self.ball_x,
990                    self.ball_y,
991                    self.ball_vx,
992                    self.ball_vy,
993                    self.right_paddle_y,
994                    self.paddle_height,
995                    self.width,
996                    self.height,
997                    dt,
998                );
999                self.right_paddle_y += ai_velocity * dt;
1000            }
1001        } else {
1002            // P1 human controls right paddle (Arrow keys) - SinglePlayer and TwoPlayer
1003            if input.is_key_pressed(jugar_input::KeyCode::Up) {
1004                self.right_paddle_y -= self.paddle_speed * dt;
1005            }
1006            if input.is_key_pressed(jugar_input::KeyCode::Down) {
1007                self.right_paddle_y += self.paddle_speed * dt;
1008            }
1009        }
1010
1011        // Clamp paddles to screen
1012        self.left_paddle_y = self
1013            .left_paddle_y
1014            .clamp(half_paddle, self.height - half_paddle);
1015        self.right_paddle_y = self
1016            .right_paddle_y
1017            .clamp(half_paddle, self.height - half_paddle);
1018
1019        // Update ball position
1020        self.ball_x += self.ball_vx * dt;
1021        self.ball_y += self.ball_vy * dt;
1022
1023        // Ball collision with top/bottom walls
1024        let mut wall_bounced = false;
1025        if self.ball_y - self.ball_radius < 0.0 {
1026            self.ball_y = self.ball_radius;
1027            self.ball_vy = self.ball_vy.abs();
1028            wall_bounced = true;
1029        } else if self.ball_y + self.ball_radius > self.height {
1030            self.ball_y = self.height - self.ball_radius;
1031            self.ball_vy = -self.ball_vy.abs();
1032            wall_bounced = true;
1033        }
1034
1035        // Trigger wall bounce juice effect and audio
1036        if wall_bounced {
1037            self.juice.on_wall_bounce();
1038            // Use velocity-based pitch variation for wall bounce
1039            let ball_speed = self.ball_vx.hypot(self.ball_vy);
1040            self.audio.on_wall_bounce_with_velocity(ball_speed, 250.0);
1041        }
1042
1043        // Track paddle hits/misses for AI difficulty adjustment
1044        let left_paddle_x = 20.0 + self.paddle_width;
1045        let right_paddle_x = self.width - 20.0 - self.paddle_width;
1046
1047        // Left paddle collision (Player 1)
1048        if self.ball_x - self.ball_radius < left_paddle_x
1049            && self.ball_x - self.ball_radius > 20.0
1050            && self.ball_y > self.left_paddle_y - half_paddle
1051            && self.ball_y < self.left_paddle_y + half_paddle
1052        {
1053            self.ball_x = left_paddle_x + self.ball_radius;
1054            self.ball_vx = self.ball_vx.abs() * 1.05; // Speed up slightly
1055
1056            // Increment rally counter
1057            self.rally_count += 1;
1058
1059            // Juice: paddle hit effect with particles
1060            self.juice.on_paddle_hit_at(self.ball_x, self.ball_y, false);
1061
1062            // Audio: paddle hit sound with pitch variation based on hit location
1063            self.audio
1064                .on_paddle_hit(self.ball_y, self.left_paddle_y, self.paddle_height);
1065
1066            // Audio: rally milestone every 5 hits
1067            if self.rally_count % 5 == 0 {
1068                self.audio.on_rally_milestone(self.rally_count);
1069            }
1070
1071            // Record player hit for AI adaptation
1072            if let Some(ref mut ai) = self.ai {
1073                ai.record_player_hit();
1074            }
1075        }
1076
1077        // Right paddle collision (AI or Player 2)
1078        if self.ball_x + self.ball_radius > right_paddle_x
1079            && self.ball_x + self.ball_radius < self.width - 20.0
1080            && self.ball_y > self.right_paddle_y - half_paddle
1081            && self.ball_y < self.right_paddle_y + half_paddle
1082        {
1083            self.ball_x = right_paddle_x - self.ball_radius;
1084            self.ball_vx = -self.ball_vx.abs() * 1.05; // Speed up slightly
1085
1086            // Increment rally counter
1087            self.rally_count += 1;
1088
1089            // Juice: paddle hit effect with particles
1090            self.juice.on_paddle_hit_at(self.ball_x, self.ball_y, true);
1091
1092            // Audio: paddle hit sound with pitch variation based on hit location
1093            self.audio
1094                .on_paddle_hit(self.ball_y, self.right_paddle_y, self.paddle_height);
1095
1096            // Audio: rally milestone every 5 hits
1097            if self.rally_count % 5 == 0 {
1098                self.audio.on_rally_milestone(self.rally_count);
1099            }
1100        }
1101
1102        // Scoring
1103        if self.ball_x < 0.0 {
1104            // Player missed - AI scores
1105            self.right_score += 1;
1106
1107            // Update high score based on rally before resetting
1108            if self.rally_count > self.high_score {
1109                self.high_score = self.rally_count;
1110            }
1111            self.rally_count = 0;
1112
1113            // Juice: goal scored effect
1114            self.juice.on_goal(self.width * 0.75, 50.0, "+1");
1115
1116            // Audio: goal sound (player did NOT score)
1117            self.audio.on_goal(false);
1118
1119            // Only run DDA when a human is playing (not in Demo mode)
1120            if self.game_mode != GameMode::Demo {
1121                if let Some(ref mut ai) = self.ai {
1122                    ai.record_player_miss();
1123                    ai.adapt_difficulty();
1124                }
1125            }
1126
1127            // Check for game over
1128            if self.right_score >= self.winning_score {
1129                self.state = GameState::GameOver;
1130            } else {
1131                self.reset_ball();
1132            }
1133        } else if self.ball_x > self.width {
1134            // AI/Player 2 missed - Player 1 scores
1135            self.left_score += 1;
1136
1137            // Update high score based on rally before resetting
1138            if self.rally_count > self.high_score {
1139                self.high_score = self.rally_count;
1140            }
1141            self.rally_count = 0;
1142
1143            // Juice: goal scored effect
1144            self.juice.on_goal(self.width * 0.25, 50.0, "+1");
1145
1146            // Audio: goal sound (player scored)
1147            self.audio.on_goal(true);
1148
1149            // Only run DDA when a human is playing (not in Demo mode)
1150            if self.game_mode != GameMode::Demo {
1151                if let Some(ref mut ai) = self.ai {
1152                    ai.adapt_difficulty();
1153                }
1154            }
1155
1156            // Check for game over
1157            if self.left_score >= self.winning_score {
1158                self.state = GameState::GameOver;
1159            } else {
1160                self.reset_ball();
1161            }
1162        }
1163
1164        // Update juice effects
1165        self.juice.update(self.ball_x, self.ball_y, dt);
1166
1167        // Update background animation timer
1168        self.bg_time += dt;
1169    }
1170
1171    #[allow(clippy::too_many_lines, clippy::suboptimal_flops)] // Render logic is inherently complex
1172    fn render(&mut self, frame: &mut RenderFrame) {
1173        // Get screen shake offset
1174        let (shake_x, shake_y) = self.juice.screen_shake.offset();
1175
1176        // Clear screen
1177        frame.clear_screen(Color::BLACK);
1178
1179        // Background animation: subtle animated dot grid
1180        let dot_spacing = 40.0;
1181        let dot_radius = 1.5;
1182        let wave_speed = 0.5;
1183        let wave_amplitude = 0.3;
1184        let num_cols = (self.width / dot_spacing).ceil() as i32;
1185        let num_rows = (self.height / dot_spacing).ceil() as i32;
1186
1187        for row in 0..num_rows {
1188            for col in 0..num_cols {
1189                let base_x = (col as f32) * dot_spacing + dot_spacing / 2.0;
1190                let base_y = (row as f32) * dot_spacing + dot_spacing / 2.0;
1191
1192                // Wave effect based on distance from center
1193                let dx = base_x - self.width / 2.0;
1194                let dy = base_y - self.height / 2.0;
1195                let dist = dx.hypot(dy);
1196                let wave_phase = dist * 0.02 - self.bg_time * wave_speed;
1197                let alpha = 0.1 + wave_amplitude * (wave_phase.sin() * 0.5 + 0.5);
1198
1199                let dot_color = Color::new(0.2, 0.2, 0.4, alpha);
1200                frame.fill_circle(base_x + shake_x, base_y + shake_y, dot_radius, dot_color);
1201            }
1202        }
1203
1204        // Draw center line (dashed effect via multiple lines)
1205        let dash_height = 20.0;
1206        let gap = 15.0;
1207        let center_x = self.width / 2.0 + shake_x;
1208        let num_dashes = (self.height / (dash_height + gap)).ceil() as usize;
1209        for i in 0..num_dashes {
1210            let y = (i as f32) * (dash_height + gap) + shake_y;
1211            frame.fill_rect(center_x - 2.0, y, 4.0, dash_height, Color::WHITE);
1212        }
1213
1214        // Draw ball trail (behind ball)
1215        for (x, y, alpha) in self.juice.ball_trail.get_points() {
1216            let trail_color = Color::new(1.0, 1.0, 1.0, alpha * 0.5);
1217            let trail_radius = self.ball_radius * (0.3 + 0.7 * alpha);
1218            frame.fill_circle(x + shake_x, y + shake_y, trail_radius, trail_color);
1219        }
1220
1221        // Get hit flash state
1222        let (left_flash, right_flash, flash_intensity) = self.juice.hit_flash.flash_state();
1223
1224        // Draw left paddle
1225        let half_paddle = self.paddle_height / 2.0;
1226        let left_paddle_x = 20.0 + shake_x;
1227        let left_paddle_top = self.left_paddle_y - half_paddle + shake_y;
1228
1229        let left_paddle_color = if left_flash {
1230            Color::new(1.0, 1.0, flash_intensity, 1.0)
1231        } else {
1232            Color::WHITE
1233        };
1234        frame.fill_rect(
1235            left_paddle_x,
1236            left_paddle_top,
1237            self.paddle_width,
1238            self.paddle_height,
1239            left_paddle_color,
1240        );
1241
1242        // Draw left paddle label (dynamic based on game mode)
1243        let left_label = self.game_mode.left_paddle_label();
1244        let label_y = left_paddle_top - 8.0; // 8px above paddle
1245        let label_color = Color::new(0.7, 0.7, 0.7, 0.9); // Subtle gray
1246        frame.fill_text_aligned(
1247            left_label,
1248            left_paddle_x + self.paddle_width / 2.0,
1249            label_y.max(15.0), // Don't go off screen
1250            "12px monospace",
1251            label_color,
1252            crate::render::TextAlign::Center,
1253            crate::render::TextBaseline::Bottom,
1254        );
1255
1256        // Draw right paddle
1257        let right_paddle_x = self.width - 20.0 - self.paddle_width + shake_x;
1258        let right_paddle_top = self.right_paddle_y - half_paddle + shake_y;
1259
1260        let right_paddle_color = if right_flash {
1261            Color::new(1.0, 1.0, flash_intensity, 1.0)
1262        } else {
1263            Color::WHITE
1264        };
1265        frame.fill_rect(
1266            right_paddle_x,
1267            right_paddle_top,
1268            self.paddle_width,
1269            self.paddle_height,
1270            right_paddle_color,
1271        );
1272
1273        // Draw right paddle label (dynamic based on game mode)
1274        let right_label = self.game_mode.right_paddle_label();
1275        let right_label_y = right_paddle_top - 8.0; // 8px above paddle
1276        frame.fill_text_aligned(
1277            right_label,
1278            right_paddle_x + self.paddle_width / 2.0,
1279            right_label_y.max(15.0), // Don't go off screen
1280            "12px monospace",
1281            label_color,
1282            crate::render::TextAlign::Center,
1283            crate::render::TextBaseline::Bottom,
1284        );
1285
1286        // Draw ball with squash/stretch based on velocity
1287        // Calculate velocity magnitude for stretch effect
1288        let speed = self.ball_vx.hypot(self.ball_vy);
1289        let base_speed = 250.0; // Reference speed for no stretch
1290        let stretch_factor = (speed / base_speed).clamp(0.8, 1.5);
1291
1292        // Calculate angle of movement for rotation
1293        let angle = self.ball_vy.atan2(self.ball_vx);
1294
1295        // Squash perpendicular to movement, stretch along movement direction
1296        // We use cos/sin to decompose the stretch into x and y components
1297        let cos_a = angle.cos();
1298        let sin_a = angle.sin();
1299
1300        // Stretch along movement direction, compress perpendicular (preserve area)
1301        let stretch_along = stretch_factor;
1302        let stretch_perp = 1.0 / stretch_factor;
1303
1304        // Calculate effective radii for x and y after rotation
1305        let rx = self.ball_radius * (stretch_along * cos_a).hypot(stretch_perp * sin_a);
1306        let ry = self.ball_radius * (stretch_along * sin_a).hypot(stretch_perp * cos_a);
1307
1308        // Draw ellipse-like ball using the larger of the two radii for circle approximation
1309        // (true ellipse would require canvas transform, this gives a subtle effect)
1310        let avg_radius = (rx + ry) / 2.0;
1311        frame.fill_circle(
1312            self.ball_x + shake_x,
1313            self.ball_y + shake_y,
1314            avg_radius,
1315            Color::WHITE,
1316        );
1317
1318        // Draw scores
1319        frame.fill_text_aligned(
1320            &self.left_score.to_string(),
1321            self.width / 4.0 + shake_x,
1322            50.0 + shake_y,
1323            "48px monospace",
1324            Color::WHITE,
1325            TextAlign::Center,
1326            TextBaseline::Top,
1327        );
1328        frame.fill_text_aligned(
1329            &self.right_score.to_string(),
1330            3.0 * self.width / 4.0 + shake_x,
1331            50.0 + shake_y,
1332            "48px monospace",
1333            Color::WHITE,
1334            TextAlign::Center,
1335            TextBaseline::Top,
1336        );
1337
1338        // Draw score popups
1339        for popup in &self.juice.score_popups {
1340            let popup_color = Color::new(1.0, 1.0, 0.0, popup.alpha()); // Yellow with alpha
1341            frame.fill_text_aligned(
1342                &popup.text,
1343                popup.x + shake_x,
1344                popup.y + shake_y,
1345                "32px monospace",
1346                popup_color,
1347                TextAlign::Center,
1348                TextBaseline::Middle,
1349            );
1350        }
1351
1352        // Draw particles
1353        for particle in self.juice.particles.get_active() {
1354            let (r, g, b) = particle.rgb();
1355            let particle_color = Color::new(r, g, b, particle.alpha());
1356            frame.fill_circle(
1357                particle.x + shake_x,
1358                particle.y + shake_y,
1359                particle.size,
1360                particle_color,
1361            );
1362        }
1363
1364        // Draw rally counter (only during gameplay with active rally)
1365        if self.state == GameState::Playing && self.rally_count > 0 {
1366            let rally_text = format!("Rally: {}", self.rally_count);
1367            // Color intensity increases with rally count
1368            let intensity = (self.rally_count as f32 / 20.0).min(1.0);
1369            let rally_color = Color::new(0.5 + intensity * 0.5, 1.0, 0.5 + intensity * 0.5, 0.8);
1370            frame.fill_text_aligned(
1371                &rally_text,
1372                self.width / 2.0 + shake_x,
1373                self.height - 30.0 + shake_y,
1374                "20px monospace",
1375                rally_color,
1376                TextAlign::Center,
1377                TextBaseline::Bottom,
1378            );
1379        }
1380
1381        // Draw high score (if set)
1382        if self.high_score > 0 {
1383            let high_score_text = format!("Best Rally: {}", self.high_score);
1384            frame.fill_text_aligned(
1385                &high_score_text,
1386                self.width / 2.0 + shake_x,
1387                20.0 + shake_y,
1388                "14px monospace",
1389                Color::new(0.5, 0.5, 0.5, 0.7),
1390                TextAlign::Center,
1391                TextBaseline::Top,
1392            );
1393        }
1394
1395        // Draw state-specific overlays
1396        match self.state {
1397            GameState::Menu => {
1398                // Semi-transparent overlay
1399                frame.fill_rect(
1400                    0.0,
1401                    0.0,
1402                    self.width,
1403                    self.height,
1404                    Color::new(0.0, 0.0, 0.0, 0.7),
1405                );
1406
1407                // Title
1408                frame.fill_text_aligned(
1409                    "PONG",
1410                    self.width / 2.0,
1411                    self.height / 3.0,
1412                    "64px monospace",
1413                    Color::WHITE,
1414                    TextAlign::Center,
1415                    TextBaseline::Middle,
1416                );
1417
1418                // Instructions
1419                frame.fill_text_aligned(
1420                    "Press SPACE to Start",
1421                    self.width / 2.0,
1422                    self.height / 2.0,
1423                    "24px monospace",
1424                    Color::new(0.7, 0.7, 0.7, 1.0),
1425                    TextAlign::Center,
1426                    TextBaseline::Middle,
1427                );
1428
1429                // Controls
1430                frame.fill_text_aligned(
1431                    "W/S - Move Paddle | ESC - Pause",
1432                    self.width / 2.0,
1433                    self.height * 0.65,
1434                    "16px monospace",
1435                    Color::new(0.5, 0.5, 0.5, 1.0),
1436                    TextAlign::Center,
1437                    TextBaseline::Middle,
1438                );
1439            }
1440            GameState::Paused => {
1441                // Semi-transparent overlay
1442                frame.fill_rect(
1443                    0.0,
1444                    0.0,
1445                    self.width,
1446                    self.height,
1447                    Color::new(0.0, 0.0, 0.0, 0.5),
1448                );
1449
1450                frame.fill_text_aligned(
1451                    "PAUSED",
1452                    self.width / 2.0,
1453                    self.height / 2.0 - 30.0,
1454                    "48px monospace",
1455                    Color::WHITE,
1456                    TextAlign::Center,
1457                    TextBaseline::Middle,
1458                );
1459
1460                frame.fill_text_aligned(
1461                    "Press SPACE or ESC to Resume",
1462                    self.width / 2.0,
1463                    self.height / 2.0 + 30.0,
1464                    "20px monospace",
1465                    Color::new(0.7, 0.7, 0.7, 1.0),
1466                    TextAlign::Center,
1467                    TextBaseline::Middle,
1468                );
1469            }
1470            GameState::GameOver => {
1471                // Semi-transparent overlay
1472                frame.fill_rect(
1473                    0.0,
1474                    0.0,
1475                    self.width,
1476                    self.height,
1477                    Color::new(0.0, 0.0, 0.0, 0.7),
1478                );
1479
1480                // Winner text
1481                let winner = if self.left_score >= self.winning_score {
1482                    "YOU WIN!"
1483                } else {
1484                    "GAME OVER"
1485                };
1486                let winner_color = if self.left_score >= self.winning_score {
1487                    Color::new(0.2, 1.0, 0.2, 1.0) // Green
1488                } else {
1489                    Color::new(1.0, 0.3, 0.3, 1.0) // Red
1490                };
1491
1492                frame.fill_text_aligned(
1493                    winner,
1494                    self.width / 2.0,
1495                    self.height / 2.0 - 40.0,
1496                    "48px monospace",
1497                    winner_color,
1498                    TextAlign::Center,
1499                    TextBaseline::Middle,
1500                );
1501
1502                // Final score
1503                let score_text = format!("{} - {}", self.left_score, self.right_score);
1504                frame.fill_text_aligned(
1505                    &score_text,
1506                    self.width / 2.0,
1507                    self.height / 2.0 + 20.0,
1508                    "32px monospace",
1509                    Color::WHITE,
1510                    TextAlign::Center,
1511                    TextBaseline::Middle,
1512                );
1513
1514                frame.fill_text_aligned(
1515                    "Press SPACE to Play Again",
1516                    self.width / 2.0,
1517                    self.height / 2.0 + 80.0,
1518                    "20px monospace",
1519                    Color::new(0.7, 0.7, 0.7, 1.0),
1520                    TextAlign::Center,
1521                    TextBaseline::Middle,
1522                );
1523            }
1524            GameState::Playing => {
1525                // No overlay when playing - but render HUD
1526            }
1527        }
1528
1529        // =========================================================================
1530        // HUD (always visible during gameplay)
1531        // =========================================================================
1532        self.render_hud(frame);
1533    }
1534
1535    fn resize(&mut self, width: u32, height: u32) {
1536        let old_width = self.width;
1537        let old_height = self.height;
1538        self.width = width as f32;
1539        self.height = height as f32;
1540
1541        // Scale positions proportionally
1542        if old_width > 0.0 && old_height > 0.0 {
1543            self.ball_x = self.ball_x * self.width / old_width;
1544            self.ball_y = self.ball_y * self.height / old_height;
1545            self.left_paddle_y = self.left_paddle_y * self.height / old_height;
1546            self.right_paddle_y = self.right_paddle_y * self.height / old_height;
1547        }
1548
1549        // Recalculate HUD button positions for new canvas size
1550        self.hud_buttons = HudButtons::calculate(self.width, self.height);
1551    }
1552}
1553
1554impl PongGame {
1555    /// Handles mouse clicks on HUD buttons.
1556    fn handle_hud_click(&mut self, mx: f32, my: f32) {
1557        // If info panel is visible, clicking anywhere dismisses it
1558        if self.show_model_info {
1559            self.show_model_info = false;
1560            return; // Consume the click
1561        }
1562
1563        // Check mode buttons
1564        if self.hud_buttons.mode_demo.contains(mx, my) {
1565            self.game_mode = GameMode::Demo;
1566            self.reset_game();
1567            self.state = GameState::Playing;
1568            self.audio.on_game_start();
1569        } else if self.hud_buttons.mode_1p.contains(mx, my) {
1570            self.game_mode = GameMode::SinglePlayer;
1571            self.reset_game();
1572            self.state = GameState::Playing;
1573            self.audio.on_game_start();
1574        } else if self.hud_buttons.mode_2p.contains(mx, my) {
1575            self.game_mode = GameMode::TwoPlayer;
1576            self.reset_game();
1577            self.state = GameState::Playing;
1578            self.audio.on_game_start();
1579        }
1580        // Check speed buttons
1581        else if self.hud_buttons.speed_1x.contains(mx, my) {
1582            self.speed_multiplier = SpeedMultiplier::Normal;
1583        } else if self.hud_buttons.speed_5x.contains(mx, my) {
1584            self.speed_multiplier = SpeedMultiplier::Fast5x;
1585        } else if self.hud_buttons.speed_10x.contains(mx, my) {
1586            self.speed_multiplier = SpeedMultiplier::Fast10x;
1587        } else if self.hud_buttons.speed_50x.contains(mx, my) {
1588            self.speed_multiplier = SpeedMultiplier::Fast50x;
1589        } else if self.hud_buttons.speed_100x.contains(mx, my) {
1590            self.speed_multiplier = SpeedMultiplier::Fast100x;
1591        } else if self.hud_buttons.speed_1000x.contains(mx, my) {
1592            self.speed_multiplier = SpeedMultiplier::Fast1000x;
1593        }
1594        // Check AI difficulty buttons
1595        else if self.hud_buttons.ai_decrease.contains(mx, my) {
1596            if let Some(ref mut ai) = self.ai {
1597                let level = ai.difficulty();
1598                if level > 0 {
1599                    ai.set_difficulty(level - 1);
1600                }
1601            }
1602        } else if self.hud_buttons.ai_increase.contains(mx, my) {
1603            if let Some(ref mut ai) = self.ai {
1604                let level = ai.difficulty();
1605                if level < 9 {
1606                    ai.set_difficulty(level + 1);
1607                }
1608            }
1609        }
1610        // Check download button
1611        else if self.hud_buttons.download.contains(mx, my) {
1612            self.download_requested = true;
1613        }
1614        // Check model info button
1615        else if self.hud_buttons.model_info.contains(mx, my) {
1616            self.show_model_info = !self.show_model_info;
1617        }
1618        // Check sound toggle button
1619        else if self.hud_buttons.sound_toggle.contains(mx, my) {
1620            self.sound_enabled = !self.sound_enabled;
1621            // Play confirmation sound when enabling audio (provides immediate feedback)
1622            self.audio.on_sound_toggle(self.sound_enabled);
1623        }
1624    }
1625
1626    /// Renders the HUD (mode buttons, speed buttons, AI info, download button).
1627    #[allow(clippy::too_many_lines, clippy::suboptimal_flops)]
1628    fn render_hud(&mut self, frame: &mut RenderFrame) {
1629        let hud_y = 10.0;
1630        let button_height = 28.0;
1631        let button_padding = 8.0;
1632        let font_size = "14px monospace";
1633        let small_font = "12px monospace";
1634
1635        // =========================================================================
1636        // Game Mode Buttons (top-left)
1637        // =========================================================================
1638        let mut mode_x = 10.0;
1639
1640        // Helper closure to render a button and return its rect
1641        let render_mode_button =
1642            |frame: &mut RenderFrame, x: f32, label: &str, is_selected: bool| -> ButtonRect {
1643                let bw = (label.len() as f32) * 10.0 + button_padding * 2.0;
1644                let bg_color = if is_selected {
1645                    Color::new(0.3, 0.6, 1.0, 0.9)
1646                } else {
1647                    Color::new(0.2, 0.2, 0.2, 0.8)
1648                };
1649                let border_color = if is_selected {
1650                    Color::WHITE
1651                } else {
1652                    Color::new(0.5, 0.5, 0.5, 1.0)
1653                };
1654                frame.fill_rect(x, hud_y, bw, button_height, bg_color);
1655                frame.stroke_rect(x, hud_y, bw, button_height, border_color, 1.0);
1656                frame.fill_text_aligned(
1657                    label,
1658                    x + bw / 2.0,
1659                    hud_y + button_height / 2.0,
1660                    font_size,
1661                    Color::WHITE,
1662                    TextAlign::Center,
1663                    TextBaseline::Middle,
1664                );
1665                ButtonRect::new(x, hud_y, bw, button_height)
1666            };
1667
1668        // Demo button
1669        self.hud_buttons.mode_demo =
1670            render_mode_button(frame, mode_x, "Demo", self.game_mode == GameMode::Demo);
1671        mode_x += self.hud_buttons.mode_demo.width + 5.0;
1672
1673        // 1P button
1674        self.hud_buttons.mode_1p = render_mode_button(
1675            frame,
1676            mode_x,
1677            "1P",
1678            self.game_mode == GameMode::SinglePlayer,
1679        );
1680        mode_x += self.hud_buttons.mode_1p.width + 5.0;
1681
1682        // 2P button
1683        self.hud_buttons.mode_2p =
1684            render_mode_button(frame, mode_x, "2P", self.game_mode == GameMode::TwoPlayer);
1685        mode_x += self.hud_buttons.mode_2p.width + 5.0;
1686
1687        // Mode keyboard hint
1688        frame.fill_text_aligned(
1689            "[M]",
1690            mode_x + 5.0,
1691            hud_y + button_height / 2.0,
1692            small_font,
1693            Color::new(0.5, 0.5, 0.5, 0.8),
1694            TextAlign::Left,
1695            TextBaseline::Middle,
1696        );
1697
1698        // =========================================================================
1699        // Speed Multiplier Buttons (top-right)
1700        // =========================================================================
1701
1702        // Helper to render a speed button with key hint
1703        let render_speed_button = |frame: &mut RenderFrame,
1704                                   x: f32,
1705                                   label: &str,
1706                                   key_hint: &str,
1707                                   is_selected: bool|
1708         -> ButtonRect {
1709            let bw = (label.len() as f32) * 8.0 + button_padding * 2.0;
1710            let bg_color = if is_selected {
1711                Color::new(1.0, 0.6, 0.2, 0.9)
1712            } else {
1713                Color::new(0.2, 0.2, 0.2, 0.8)
1714            };
1715            let border_color = if is_selected {
1716                Color::WHITE
1717            } else {
1718                Color::new(0.5, 0.5, 0.5, 1.0)
1719            };
1720            frame.fill_rect(x, hud_y, bw, button_height, bg_color);
1721            frame.stroke_rect(x, hud_y, bw, button_height, border_color, 1.0);
1722            frame.fill_text_aligned(
1723                label,
1724                x + bw / 2.0,
1725                hud_y + button_height / 2.0,
1726                font_size,
1727                Color::WHITE,
1728                TextAlign::Center,
1729                TextBaseline::Middle,
1730            );
1731            frame.fill_text_aligned(
1732                key_hint,
1733                x + bw / 2.0,
1734                hud_y + button_height + 3.0,
1735                "10px monospace",
1736                Color::new(0.4, 0.4, 0.4, 0.7),
1737                TextAlign::Center,
1738                TextBaseline::Top,
1739            );
1740            ButtonRect::new(x, hud_y, bw, button_height)
1741        };
1742
1743        // Calculate widths for positioning
1744        let w1x = "1x".len() as f32 * 8.0 + button_padding * 2.0;
1745        let w5x = "5x".len() as f32 * 8.0 + button_padding * 2.0;
1746        let w10x = "10x".len() as f32 * 8.0 + button_padding * 2.0;
1747        let w50x = "50x".len() as f32 * 8.0 + button_padding * 2.0;
1748        let w100x = "100x".len() as f32 * 8.0 + button_padding * 2.0;
1749        let w1000x = "1000x".len() as f32 * 8.0 + button_padding * 2.0;
1750        let total_width = w1x + w5x + w10x + w50x + w100x + w1000x + 25.0; // 5 gaps * 5px
1751
1752        let mut speed_x = self.width - total_width - 10.0;
1753
1754        self.hud_buttons.speed_1x = render_speed_button(
1755            frame,
1756            speed_x,
1757            "1x",
1758            "1",
1759            self.speed_multiplier == SpeedMultiplier::Normal,
1760        );
1761        speed_x += w1x + 5.0;
1762
1763        self.hud_buttons.speed_5x = render_speed_button(
1764            frame,
1765            speed_x,
1766            "5x",
1767            "2",
1768            self.speed_multiplier == SpeedMultiplier::Fast5x,
1769        );
1770        speed_x += w5x + 5.0;
1771
1772        self.hud_buttons.speed_10x = render_speed_button(
1773            frame,
1774            speed_x,
1775            "10x",
1776            "3",
1777            self.speed_multiplier == SpeedMultiplier::Fast10x,
1778        );
1779        speed_x += w10x + 5.0;
1780
1781        self.hud_buttons.speed_50x = render_speed_button(
1782            frame,
1783            speed_x,
1784            "50x",
1785            "4",
1786            self.speed_multiplier == SpeedMultiplier::Fast50x,
1787        );
1788        speed_x += w50x + 5.0;
1789
1790        self.hud_buttons.speed_100x = render_speed_button(
1791            frame,
1792            speed_x,
1793            "100x",
1794            "5",
1795            self.speed_multiplier == SpeedMultiplier::Fast100x,
1796        );
1797        speed_x += w100x + 5.0;
1798
1799        self.hud_buttons.speed_1000x = render_speed_button(
1800            frame,
1801            speed_x,
1802            "1000x",
1803            "6",
1804            self.speed_multiplier == SpeedMultiplier::Fast1000x,
1805        );
1806
1807        // =========================================================================
1808        // AI Difficulty Indicator (below mode buttons)
1809        // =========================================================================
1810        let ai_y = hud_y + button_height + 15.0;
1811        let ai_level = self.ai.as_ref().map_or(5, crate::ai::PongAI::difficulty);
1812        let ai_name = self
1813            .ai
1814            .as_ref()
1815            .map_or("Normal", crate::ai::PongAI::difficulty_name);
1816
1817        // AI label
1818        frame.fill_text_aligned(
1819            "AI:",
1820            10.0,
1821            ai_y,
1822            font_size,
1823            Color::new(0.7, 0.7, 0.7, 1.0),
1824            TextAlign::Left,
1825            TextBaseline::Top,
1826        );
1827
1828        // Progress bar background
1829        let bar_x = 40.0;
1830        let bar_width = 100.0;
1831        let bar_height = 12.0;
1832        frame.fill_rect(
1833            bar_x,
1834            ai_y + 2.0,
1835            bar_width,
1836            bar_height,
1837            Color::new(0.2, 0.2, 0.2, 0.8),
1838        );
1839
1840        // Progress bar fill
1841        let fill_width = (f32::from(ai_level) / 9.0) * bar_width;
1842        let fill_color = match ai_level {
1843            0..=2 => Color::new(0.2, 0.8, 0.2, 1.0), // Green (easy)
1844            3..=5 => Color::new(0.8, 0.8, 0.2, 1.0), // Yellow (medium)
1845            6..=7 => Color::new(0.8, 0.5, 0.2, 1.0), // Orange (hard)
1846            _ => Color::new(0.8, 0.2, 0.2, 1.0),     // Red (expert)
1847        };
1848        frame.fill_rect(bar_x, ai_y + 2.0, fill_width, bar_height, fill_color);
1849
1850        // Progress bar border
1851        frame.stroke_rect(
1852            bar_x,
1853            ai_y + 2.0,
1854            bar_width,
1855            bar_height,
1856            Color::new(0.5, 0.5, 0.5, 1.0),
1857            1.0,
1858        );
1859
1860        // AI level text
1861        let ai_text = format!("{ai_level}/9 {ai_name}");
1862        frame.fill_text_aligned(
1863            &ai_text,
1864            bar_x + bar_width + 10.0,
1865            ai_y,
1866            small_font,
1867            Color::new(0.7, 0.7, 0.7, 1.0),
1868            TextAlign::Left,
1869            TextBaseline::Top,
1870        );
1871
1872        // AI difficulty +/- buttons (positioned after "X/9 Nightmare" text ~90px)
1873        let ai_btn_size = 20.0;
1874        let ai_btn_y = ai_y - 2.0;
1875        let ai_btn_x = bar_x + bar_width + 100.0;
1876
1877        // - button
1878        frame.fill_rect(
1879            ai_btn_x,
1880            ai_btn_y,
1881            ai_btn_size,
1882            ai_btn_size,
1883            Color::new(0.3, 0.3, 0.3, 0.9),
1884        );
1885        frame.stroke_rect(
1886            ai_btn_x,
1887            ai_btn_y,
1888            ai_btn_size,
1889            ai_btn_size,
1890            Color::new(0.5, 0.5, 0.5, 1.0),
1891            1.0,
1892        );
1893        frame.fill_text_aligned(
1894            "-",
1895            ai_btn_x + ai_btn_size / 2.0,
1896            ai_btn_y + ai_btn_size / 2.0,
1897            font_size,
1898            Color::WHITE,
1899            TextAlign::Center,
1900            TextBaseline::Middle,
1901        );
1902        self.hud_buttons.ai_decrease =
1903            ButtonRect::new(ai_btn_x, ai_btn_y, ai_btn_size, ai_btn_size);
1904
1905        // + button
1906        let ai_plus_x = ai_btn_x + ai_btn_size + 5.0;
1907        frame.fill_rect(
1908            ai_plus_x,
1909            ai_btn_y,
1910            ai_btn_size,
1911            ai_btn_size,
1912            Color::new(0.3, 0.3, 0.3, 0.9),
1913        );
1914        frame.stroke_rect(
1915            ai_plus_x,
1916            ai_btn_y,
1917            ai_btn_size,
1918            ai_btn_size,
1919            Color::new(0.5, 0.5, 0.5, 1.0),
1920            1.0,
1921        );
1922        frame.fill_text_aligned(
1923            "+",
1924            ai_plus_x + ai_btn_size / 2.0,
1925            ai_btn_y + ai_btn_size / 2.0,
1926            font_size,
1927            Color::WHITE,
1928            TextAlign::Center,
1929            TextBaseline::Middle,
1930        );
1931        self.hud_buttons.ai_increase =
1932            ButtonRect::new(ai_plus_x, ai_btn_y, ai_btn_size, ai_btn_size);
1933
1934        // =========================================================================
1935        // Download .apr Button (bottom-left)
1936        // =========================================================================
1937        let download_y = self.height - 45.0;
1938        let download_text = "Download .apr";
1939        let download_width = (download_text.len() as f32) * 8.0 + button_padding * 2.0;
1940
1941        // Button background
1942        frame.fill_rect(
1943            10.0,
1944            download_y,
1945            download_width,
1946            button_height,
1947            Color::new(0.1, 0.4, 0.2, 0.8),
1948        );
1949
1950        // Button border
1951        frame.stroke_rect(
1952            10.0,
1953            download_y,
1954            download_width,
1955            button_height,
1956            Color::new(0.3, 0.7, 0.4, 1.0),
1957            1.0,
1958        );
1959
1960        // Store download button for click handling
1961        self.hud_buttons.download =
1962            ButtonRect::new(10.0, download_y, download_width, button_height);
1963
1964        // Button text
1965        frame.fill_text_aligned(
1966            download_text,
1967            10.0 + download_width / 2.0,
1968            download_y + button_height / 2.0,
1969            font_size,
1970            Color::WHITE,
1971            TextAlign::Center,
1972            TextBaseline::Middle,
1973        );
1974
1975        // =========================================================================
1976        // Info Button (next to Download)
1977        // =========================================================================
1978        let info_text = "Info";
1979        let info_width = (info_text.len() as f32) * 8.0 + button_padding * 2.0;
1980        let info_x = 10.0 + download_width + 5.0;
1981
1982        // Info button background (purple/blue tint to distinguish from download)
1983        let info_bg = if self.show_model_info {
1984            Color::new(0.3, 0.3, 0.7, 0.9) // Brighter when active
1985        } else {
1986            Color::new(0.2, 0.2, 0.4, 0.8)
1987        };
1988        frame.fill_rect(info_x, download_y, info_width, button_height, info_bg);
1989
1990        // Info button border
1991        frame.stroke_rect(
1992            info_x,
1993            download_y,
1994            info_width,
1995            button_height,
1996            Color::new(0.4, 0.4, 0.8, 1.0),
1997            1.0,
1998        );
1999
2000        // Store info button for click handling
2001        self.hud_buttons.model_info =
2002            ButtonRect::new(info_x, download_y, info_width, button_height);
2003
2004        // Info button text
2005        frame.fill_text_aligned(
2006            info_text,
2007            info_x + info_width / 2.0,
2008            download_y + button_height / 2.0,
2009            font_size,
2010            Color::WHITE,
2011            TextAlign::Center,
2012            TextBaseline::Middle,
2013        );
2014
2015        // Keyboard hint for Info button
2016        frame.fill_text_aligned(
2017            "[I]",
2018            info_x + info_width + 5.0,
2019            download_y + button_height / 2.0,
2020            "10px monospace",
2021            Color::new(0.4, 0.4, 0.4, 0.7),
2022            TextAlign::Left,
2023            TextBaseline::Middle,
2024        );
2025
2026        // =========================================================================
2027        // Sound Toggle Button (next to Info, after hint)
2028        // =========================================================================
2029        let sound_text = if self.sound_enabled { "Sound" } else { "Muted" };
2030        let sound_width = 64.0; // Fixed width for consistent layout
2031        let sound_x = info_x + info_width + 25.0; // After [I] hint
2032
2033        // Sound button background (green when on, red when muted)
2034        let sound_bg = if self.sound_enabled {
2035            Color::new(0.2, 0.4, 0.2, 0.8)
2036        } else {
2037            Color::new(0.4, 0.2, 0.2, 0.8)
2038        };
2039        frame.fill_rect(sound_x, download_y, sound_width, button_height, sound_bg);
2040
2041        // Sound button border
2042        let sound_border = if self.sound_enabled {
2043            Color::new(0.3, 0.7, 0.3, 1.0)
2044        } else {
2045            Color::new(0.7, 0.3, 0.3, 1.0)
2046        };
2047        frame.stroke_rect(
2048            sound_x,
2049            download_y,
2050            sound_width,
2051            button_height,
2052            sound_border,
2053            1.0,
2054        );
2055
2056        // Store sound button for click handling
2057        self.hud_buttons.sound_toggle =
2058            ButtonRect::new(sound_x, download_y, sound_width, button_height);
2059
2060        // Sound button text
2061        frame.fill_text_aligned(
2062            sound_text,
2063            sound_x + sound_width / 2.0,
2064            download_y + button_height / 2.0,
2065            font_size,
2066            Color::WHITE,
2067            TextAlign::Center,
2068            TextBaseline::Middle,
2069        );
2070
2071        // =========================================================================
2072        // AI Explainability Widget (upper-right, below speed buttons)
2073        // =========================================================================
2074        self.render_ai_explain_widget(frame);
2075
2076        // =========================================================================
2077        // Model Info Panel (shown when show_model_info is true)
2078        // =========================================================================
2079        if self.show_model_info {
2080            self.render_model_info_panel(frame);
2081        }
2082
2083        // Footer links (bottom-right, stacked)
2084        let footer_x = self.width - 10.0;
2085        let footer_y = self.height - 10.0;
2086        let link_color = Color::new(0.4, 0.4, 0.4, 0.7);
2087        let link_spacing = 14.0;
2088
2089        // Line 1: PAIML website
2090        frame.fill_text_aligned(
2091            "paiml.com",
2092            footer_x,
2093            footer_y - link_spacing * 2.0,
2094            small_font,
2095            link_color,
2096            TextAlign::Right,
2097            TextBaseline::Bottom,
2098        );
2099
2100        // Line 2: Jugar repo
2101        frame.fill_text_aligned(
2102            "github.com/paiml/jugar",
2103            footer_x,
2104            footer_y - link_spacing,
2105            small_font,
2106            link_color,
2107            TextAlign::Right,
2108            TextBaseline::Bottom,
2109        );
2110
2111        // Line 3: Aprender (APR format) repo
2112        frame.fill_text_aligned(
2113            ".apr format: github.com/paiml/aprender",
2114            footer_x,
2115            footer_y,
2116            small_font,
2117            link_color,
2118            TextAlign::Right,
2119            TextBaseline::Bottom,
2120        );
2121    }
2122
2123    /// Renders the model info panel showing .apr metadata.
2124    ///
2125    /// Panel displays:
2126    /// - Model name, version, author, license
2127    /// - Flow Theory parameters
2128    /// - Current AI difficulty and state
2129    /// - Difficulty curve visualization
2130    #[allow(clippy::too_many_lines)]
2131    fn render_model_info_panel(&self, frame: &mut RenderFrame) {
2132        // Panel dimensions - centered, semi-transparent overlay
2133        let panel_width = 320.0;
2134        let panel_height = 340.0;
2135        let panel_x = (self.width - panel_width) / 2.0;
2136        let panel_y = (self.height - panel_height) / 2.0;
2137
2138        // Semi-transparent dark background
2139        frame.fill_rect(
2140            panel_x,
2141            panel_y,
2142            panel_width,
2143            panel_height,
2144            Color::new(0.05, 0.05, 0.1, 0.95),
2145        );
2146
2147        // Panel border
2148        frame.stroke_rect(
2149            panel_x,
2150            panel_y,
2151            panel_width,
2152            panel_height,
2153            Color::new(0.3, 0.3, 0.8, 1.0),
2154            2.0,
2155        );
2156
2157        let title_font = "16px monospace";
2158        let label_font = "12px monospace";
2159        let value_font = "14px monospace";
2160
2161        let text_color = Color::WHITE;
2162        let label_color = Color::new(0.6, 0.6, 0.8, 1.0);
2163        let highlight_color = Color::new(0.4, 0.8, 1.0, 1.0);
2164
2165        let mut y = panel_y + 25.0;
2166        let left_margin = panel_x + 15.0;
2167        let value_x = panel_x + 140.0;
2168        let line_height = 22.0;
2169
2170        // Title
2171        frame.fill_text_aligned(
2172            ".apr Model Info",
2173            panel_x + panel_width / 2.0,
2174            y,
2175            title_font,
2176            highlight_color,
2177            TextAlign::Center,
2178            TextBaseline::Middle,
2179        );
2180        y += line_height + 8.0;
2181
2182        // Separator line
2183        frame.fill_rect(
2184            panel_x + 10.0,
2185            y,
2186            panel_width - 20.0,
2187            1.0,
2188            Color::new(0.3, 0.3, 0.5, 0.8),
2189        );
2190        y += 12.0;
2191
2192        // Get model info from AI (or use defaults)
2193        let ai = self.ai.as_ref();
2194        let default_model = crate::ai::PongAIModel::default();
2195        let model = ai.map_or(&default_model, crate::ai::PongAI::model);
2196
2197        // === Metadata Section ===
2198        frame.fill_text_aligned(
2199            "METADATA",
2200            left_margin,
2201            y,
2202            label_font,
2203            label_color,
2204            TextAlign::Left,
2205            TextBaseline::Middle,
2206        );
2207        y += line_height;
2208
2209        // Model name
2210        frame.fill_text_aligned(
2211            "Model:",
2212            left_margin,
2213            y,
2214            label_font,
2215            label_color,
2216            TextAlign::Left,
2217            TextBaseline::Middle,
2218        );
2219        frame.fill_text_aligned(
2220            &model.metadata.name,
2221            value_x,
2222            y,
2223            value_font,
2224            text_color,
2225            TextAlign::Left,
2226            TextBaseline::Middle,
2227        );
2228        y += line_height;
2229
2230        // Version
2231        frame.fill_text_aligned(
2232            "Version:",
2233            left_margin,
2234            y,
2235            label_font,
2236            label_color,
2237            TextAlign::Left,
2238            TextBaseline::Middle,
2239        );
2240        frame.fill_text_aligned(
2241            &model.metadata.version,
2242            value_x,
2243            y,
2244            value_font,
2245            text_color,
2246            TextAlign::Left,
2247            TextBaseline::Middle,
2248        );
2249        y += line_height;
2250
2251        // Author
2252        frame.fill_text_aligned(
2253            "Author:",
2254            left_margin,
2255            y,
2256            label_font,
2257            label_color,
2258            TextAlign::Left,
2259            TextBaseline::Middle,
2260        );
2261        frame.fill_text_aligned(
2262            &model.metadata.author,
2263            value_x,
2264            y,
2265            value_font,
2266            text_color,
2267            TextAlign::Left,
2268            TextBaseline::Middle,
2269        );
2270        y += line_height;
2271
2272        // License
2273        frame.fill_text_aligned(
2274            "License:",
2275            left_margin,
2276            y,
2277            label_font,
2278            label_color,
2279            TextAlign::Left,
2280            TextBaseline::Middle,
2281        );
2282        frame.fill_text_aligned(
2283            &model.metadata.license,
2284            value_x,
2285            y,
2286            value_font,
2287            text_color,
2288            TextAlign::Left,
2289            TextBaseline::Middle,
2290        );
2291        y += line_height + 8.0;
2292
2293        // === Flow Theory Section ===
2294        frame.fill_text_aligned(
2295            "FLOW THEORY (DDA)",
2296            left_margin,
2297            y,
2298            label_font,
2299            label_color,
2300            TextAlign::Left,
2301            TextBaseline::Middle,
2302        );
2303        y += line_height;
2304
2305        // Adaptation rate
2306        frame.fill_text_aligned(
2307            "Adapt Rate:",
2308            left_margin,
2309            y,
2310            label_font,
2311            label_color,
2312            TextAlign::Left,
2313            TextBaseline::Middle,
2314        );
2315        let adapt_text = format!("{:.0}%", model.flow_theory.adaptation_rate * 100.0);
2316        frame.fill_text_aligned(
2317            &adapt_text,
2318            value_x,
2319            y,
2320            value_font,
2321            text_color,
2322            TextAlign::Left,
2323            TextBaseline::Middle,
2324        );
2325        y += line_height;
2326
2327        // Target win rate
2328        frame.fill_text_aligned(
2329            "Target Win:",
2330            left_margin,
2331            y,
2332            label_font,
2333            label_color,
2334            TextAlign::Left,
2335            TextBaseline::Middle,
2336        );
2337        let target_text = format!("{:.0}%", model.flow_theory.target_win_rate * 100.0);
2338        frame.fill_text_aligned(
2339            &target_text,
2340            value_x,
2341            y,
2342            value_font,
2343            text_color,
2344            TextAlign::Left,
2345            TextBaseline::Middle,
2346        );
2347        y += line_height;
2348
2349        // Flow thresholds
2350        frame.fill_text_aligned(
2351            "Flow Range:",
2352            left_margin,
2353            y,
2354            label_font,
2355            label_color,
2356            TextAlign::Left,
2357            TextBaseline::Middle,
2358        );
2359        let range_text = format!(
2360            "{:.0}%-{:.0}%",
2361            model.flow_theory.anxiety_threshold * 100.0,
2362            model.flow_theory.boredom_threshold * 100.0
2363        );
2364        frame.fill_text_aligned(
2365            &range_text,
2366            value_x,
2367            y,
2368            value_font,
2369            text_color,
2370            TextAlign::Left,
2371            TextBaseline::Middle,
2372        );
2373        y += line_height + 8.0;
2374
2375        // === Current State Section ===
2376        frame.fill_text_aligned(
2377            "CURRENT STATE",
2378            left_margin,
2379            y,
2380            label_font,
2381            label_color,
2382            TextAlign::Left,
2383            TextBaseline::Middle,
2384        );
2385        y += line_height;
2386
2387        // AI difficulty level
2388        let ai_level = ai.map_or(5, crate::ai::PongAI::difficulty);
2389        let ai_name = ai.map_or("Normal", crate::ai::PongAI::difficulty_name);
2390        frame.fill_text_aligned(
2391            "Difficulty:",
2392            left_margin,
2393            y,
2394            label_font,
2395            label_color,
2396            TextAlign::Left,
2397            TextBaseline::Middle,
2398        );
2399        let level_text = format!("{ai_level}/9 {ai_name}");
2400        frame.fill_text_aligned(
2401            &level_text,
2402            value_x,
2403            y,
2404            value_font,
2405            highlight_color,
2406            TextAlign::Left,
2407            TextBaseline::Middle,
2408        );
2409        y += line_height;
2410
2411        // Difficulty bar visualization
2412        let bar_x = left_margin;
2413        let bar_width = panel_width - 30.0;
2414        let bar_height = 12.0;
2415
2416        // Background
2417        frame.fill_rect(
2418            bar_x,
2419            y,
2420            bar_width,
2421            bar_height,
2422            Color::new(0.15, 0.15, 0.2, 1.0),
2423        );
2424
2425        // Fill based on level
2426        let fill_width = (f32::from(ai_level) / 9.0) * bar_width;
2427        let fill_color = match ai_level {
2428            0..=2 => Color::new(0.2, 0.8, 0.2, 1.0), // Green (easy)
2429            3..=5 => Color::new(0.8, 0.8, 0.2, 1.0), // Yellow (medium)
2430            6..=7 => Color::new(0.8, 0.5, 0.2, 1.0), // Orange (hard)
2431            _ => Color::new(0.8, 0.2, 0.2, 1.0),     // Red (expert)
2432        };
2433        frame.fill_rect(bar_x, y, fill_width, bar_height, fill_color);
2434
2435        // Bar border
2436        frame.stroke_rect(
2437            bar_x,
2438            y,
2439            bar_width,
2440            bar_height,
2441            Color::new(0.4, 0.4, 0.6, 1.0),
2442            1.0,
2443        );
2444        y += bar_height + 8.0;
2445
2446        // File size
2447        frame.fill_text_aligned(
2448            "File Size:",
2449            left_margin,
2450            y,
2451            label_font,
2452            label_color,
2453            TextAlign::Left,
2454            TextBaseline::Middle,
2455        );
2456        let size_text = format!("{} bytes", model.serialized_size());
2457        frame.fill_text_aligned(
2458            &size_text,
2459            value_x,
2460            y,
2461            value_font,
2462            text_color,
2463            TextAlign::Left,
2464            TextBaseline::Middle,
2465        );
2466
2467        // Close hint at bottom
2468        frame.fill_text_aligned(
2469            "Press [I] or click Info to close",
2470            panel_x + panel_width / 2.0,
2471            panel_y + panel_height - 15.0,
2472            "10px monospace",
2473            Color::new(0.5, 0.5, 0.5, 0.8),
2474            TextAlign::Center,
2475            TextBaseline::Middle,
2476        );
2477    }
2478
2479    /// Renders the AI explainability widget in the upper-right corner.
2480    ///
2481    /// Shows real-time SHAP-style feature contributions from the `.apr` model:
2482    /// - Current decision state (IDLE, REACT, TRACK, READY)
2483    /// - Feature contribution bars
2484    /// - Decision rationale
2485    fn render_ai_explain_widget(&self, frame: &mut RenderFrame) {
2486        // Only show when AI is active (Demo or SinglePlayer mode)
2487        if !matches!(self.game_mode, GameMode::Demo | GameMode::SinglePlayer) {
2488            return;
2489        }
2490
2491        // In Demo mode, show widgets for both AIs
2492        if self.game_mode == GameMode::Demo {
2493            // Left AI widget (upper-left)
2494            if let Some(left_ai) = self.left_ai.as_ref() {
2495                self.render_single_ai_widget(frame, left_ai, 10.0, "P1 .apr Model");
2496            }
2497            // Right AI widget (upper-right)
2498            if let Some(right_ai) = self.ai.as_ref() {
2499                self.render_single_ai_widget(frame, right_ai, self.width - 210.0, "P2 .apr Model");
2500            }
2501        } else {
2502            // SinglePlayer mode - show left AI widget (AI opponent is on the left)
2503            if let Some(left_ai) = self.left_ai.as_ref() {
2504                self.render_single_ai_widget(frame, left_ai, 10.0, ".apr ML Model");
2505            }
2506        }
2507    }
2508
2509    /// Renders a single AI explanation widget at the given x position.
2510    #[allow(clippy::unused_self, clippy::too_many_lines)]
2511    fn render_single_ai_widget(
2512        &self,
2513        frame: &mut RenderFrame,
2514        ai: &PongAI,
2515        widget_x: f32,
2516        title: &str,
2517    ) {
2518        let explanation = ai.explanation();
2519
2520        // Widget dimensions - compact
2521        let widget_width = 200.0;
2522        let widget_height = 180.0;
2523        let widget_y = 80.0; // Below mode buttons and AI difficulty row
2524
2525        // Semi-transparent dark background
2526        frame.fill_rect(
2527            widget_x,
2528            widget_y,
2529            widget_width,
2530            widget_height,
2531            Color::new(0.02, 0.02, 0.08, 0.85),
2532        );
2533
2534        // Cyan border (matches aprender branding)
2535        frame.stroke_rect(
2536            widget_x,
2537            widget_y,
2538            widget_width,
2539            widget_height,
2540            Color::new(0.2, 0.8, 0.9, 0.9),
2541            1.0,
2542        );
2543
2544        let title_font = "12px monospace";
2545        let small_font = "10px monospace";
2546        let tiny_font = "9px monospace";
2547
2548        let text_color = Color::WHITE;
2549        let label_color = Color::new(0.6, 0.7, 0.8, 1.0);
2550        let cyan = Color::new(0.2, 0.8, 0.9, 1.0);
2551
2552        let mut y = widget_y + 15.0;
2553        let left_margin = widget_x + 8.0;
2554        let line_height = 14.0;
2555
2556        // Title with state indicator
2557        let state_color = match explanation.state {
2558            crate::ai::DecisionState::Idle => Color::new(0.5, 0.5, 0.5, 1.0),
2559            crate::ai::DecisionState::Reacting => Color::new(1.0, 0.8, 0.2, 1.0),
2560            crate::ai::DecisionState::Tracking => Color::new(0.2, 1.0, 0.4, 1.0),
2561            crate::ai::DecisionState::Ready => Color::new(0.2, 0.6, 1.0, 1.0),
2562        };
2563
2564        frame.fill_text_aligned(
2565            title,
2566            left_margin,
2567            y,
2568            title_font,
2569            cyan,
2570            TextAlign::Left,
2571            TextBaseline::Middle,
2572        );
2573
2574        // State badge
2575        frame.fill_text_aligned(
2576            explanation.state.code(),
2577            widget_x + widget_width - 10.0,
2578            y,
2579            title_font,
2580            state_color,
2581            TextAlign::Right,
2582            TextBaseline::Middle,
2583        );
2584        y += line_height + 4.0;
2585
2586        // Separator
2587        frame.fill_rect(
2588            left_margin,
2589            y,
2590            widget_width - 16.0,
2591            1.0,
2592            Color::new(0.3, 0.4, 0.5, 0.6),
2593        );
2594        y += 6.0;
2595
2596        // Feature contributions with bars
2597        let bar_max_width = 80.0;
2598        let bar_height = 8.0;
2599        let bar_x = widget_x + widget_width - bar_max_width - 10.0;
2600
2601        // Show top 4 contributions
2602        for contrib in explanation.contributions.iter().take(4) {
2603            // Feature name (truncated)
2604            let name = if contrib.name.len() > 12 {
2605                format!("{}...", &contrib.name[..9])
2606            } else {
2607                contrib.name.clone()
2608            };
2609
2610            frame.fill_text_aligned(
2611                &name,
2612                left_margin,
2613                y + bar_height / 2.0,
2614                tiny_font,
2615                label_color,
2616                TextAlign::Left,
2617                TextBaseline::Middle,
2618            );
2619
2620            // Contribution bar background
2621            frame.fill_rect(
2622                bar_x,
2623                y,
2624                bar_max_width,
2625                bar_height,
2626                Color::new(0.1, 0.1, 0.15, 1.0),
2627            );
2628
2629            // Contribution bar fill
2630            let bar_width = contrib.importance * bar_max_width;
2631            let bar_color = if contrib.contribution >= 0.0 {
2632                Color::new(0.2, 0.7, 0.3, 0.9) // Green for positive
2633            } else {
2634                Color::new(0.7, 0.3, 0.2, 0.9) // Red for negative
2635            };
2636            frame.fill_rect(bar_x, y, bar_width, bar_height, bar_color);
2637
2638            y += line_height;
2639        }
2640
2641        y += 4.0;
2642
2643        // Separator
2644        frame.fill_rect(
2645            left_margin,
2646            y,
2647            widget_width - 16.0,
2648            1.0,
2649            Color::new(0.3, 0.4, 0.5, 0.6),
2650        );
2651        y += 6.0;
2652
2653        // Model params row
2654        let params_text = format!(
2655            "L{} | {:.0}ms | {:.0}%acc",
2656            explanation.difficulty_level,
2657            explanation.reaction_delay_ms,
2658            explanation.prediction_accuracy * 100.0
2659        );
2660        frame.fill_text_aligned(
2661            &params_text,
2662            left_margin,
2663            y,
2664            tiny_font,
2665            label_color,
2666            TextAlign::Left,
2667            TextBaseline::Middle,
2668        );
2669        y += line_height;
2670
2671        // Decision rationale (wrapped if needed)
2672        let rationale = if explanation.rationale.len() > 30 {
2673            format!("{}...", &explanation.rationale[..27])
2674        } else {
2675            explanation.rationale.clone()
2676        };
2677        frame.fill_text_aligned(
2678            &rationale,
2679            left_margin,
2680            y,
2681            small_font,
2682            text_color,
2683            TextAlign::Left,
2684            TextBaseline::Middle,
2685        );
2686    }
2687}
2688
2689/// The main web platform struct exposed to JavaScript via wasm-bindgen.
2690///
2691/// This handles the game loop, input processing, and render command generation.
2692/// All computation happens in Rust; JavaScript only forwards events and draws.
2693#[wasm_bindgen]
2694#[allow(missing_debug_implementations)] // Cannot derive Debug with wasm_bindgen
2695pub struct WebPlatform {
2696    /// Configuration
2697    config: WebConfig,
2698    /// Frame timer for delta time calculation
2699    timer: FrameTimer,
2700    /// Input state
2701    input: InputState,
2702    /// Render frame buffer
2703    render_frame: RenderFrame,
2704    /// Current game (stored as boxed trait object)
2705    #[allow(dead_code)]
2706    game: Option<Box<dyn WebGame>>,
2707    /// Pong game (direct storage for WASM simplicity)
2708    pong: PongGame,
2709    /// Frame counter
2710    frame_count: u64,
2711    /// Canvas offset X from viewport origin
2712    canvas_offset_x: f32,
2713    /// Canvas offset Y from viewport origin
2714    canvas_offset_y: f32,
2715    /// Game tracer for replay recording (only active in debug mode)
2716    tracer: GameTracer,
2717}
2718
2719#[wasm_bindgen]
2720impl WebPlatform {
2721    /// Creates a new `WebPlatform` with configuration from JSON.
2722    ///
2723    /// # Arguments
2724    ///
2725    /// * `config_json` - JSON string with configuration
2726    ///
2727    /// # Errors
2728    ///
2729    /// Returns a JavaScript error if the configuration is invalid.
2730    #[wasm_bindgen(constructor)]
2731    pub fn new(config_json: &str) -> Result<Self, JsValue> {
2732        let config: WebConfig = serde_json::from_str(config_json)
2733            .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
2734
2735        let fixed_dt = 1.0 / f64::from(config.target_fps);
2736        let mut timer = FrameTimer::new();
2737        timer.set_fixed_dt(fixed_dt);
2738
2739        let pong = PongGame::new(config.width as f32, config.height as f32, config.ai_enabled);
2740
2741        // Use debug tracer in debug mode (Andon Cord), production tracer otherwise
2742        let tracer = if config.debug {
2743            GameTracer::debug()
2744        } else {
2745            GameTracer::production()
2746        };
2747
2748        Ok(Self {
2749            config,
2750            timer,
2751            input: InputState::new(),
2752            render_frame: RenderFrame::with_capacity(100),
2753            game: None,
2754            pong,
2755            frame_count: 0,
2756            canvas_offset_x: 0.0,
2757            canvas_offset_y: 0.0,
2758            tracer,
2759        })
2760    }
2761
2762    /// Creates a new `WebPlatform` with default configuration.
2763    #[wasm_bindgen(js_name = "newDefault")]
2764    #[must_use]
2765    pub fn new_default() -> Self {
2766        let config = WebConfig::default();
2767        let pong = PongGame::new(config.width as f32, config.height as f32, config.ai_enabled);
2768
2769        Self {
2770            config,
2771            timer: FrameTimer::new(),
2772            input: InputState::new(),
2773            render_frame: RenderFrame::with_capacity(100),
2774            game: None,
2775            pong,
2776            frame_count: 0,
2777            canvas_offset_x: 0.0,
2778            canvas_offset_y: 0.0,
2779            tracer: GameTracer::production(), // Default to production mode
2780        }
2781    }
2782
2783    /// Sets the canvas offset from viewport origin.
2784    #[wasm_bindgen(js_name = "setCanvasOffset")]
2785    #[allow(clippy::missing_const_for_fn)]
2786    pub fn set_canvas_offset(&mut self, x: f32, y: f32) {
2787        self.canvas_offset_x = x;
2788        self.canvas_offset_y = y;
2789    }
2790
2791    /// Processes a single frame.
2792    ///
2793    /// This is called from `requestAnimationFrame`. All game logic runs here.
2794    ///
2795    /// # Arguments
2796    ///
2797    /// * `timestamp` - `DOMHighResTimeStamp` from `requestAnimationFrame`
2798    /// * `input_events_json` - JSON array of input events since last frame
2799    ///
2800    /// # Returns
2801    ///
2802    /// JSON string with render commands for Canvas2D execution.
2803    #[wasm_bindgen]
2804    pub fn frame(&mut self, timestamp: f64, input_events_json: &str) -> String {
2805        // Begin trace frame recording
2806        self.tracer.begin_frame();
2807
2808        // Update timer and get delta time
2809        let dt = self.timer.update(timestamp);
2810
2811        // Process input events with canvas offset for coordinate conversion
2812        let canvas_offset = Vec2::new(self.canvas_offset_x, self.canvas_offset_y);
2813        // Ignore errors for now - invalid events are just skipped
2814        let _ = process_input_events(input_events_json, &mut self.input, canvas_offset);
2815
2816        // Update game logic
2817        self.pong.update(&self.input, dt);
2818
2819        // Clear input events for next frame (key presses persist, events don't)
2820        self.input.clear_events();
2821
2822        // Generate render commands
2823        self.render_frame.clear();
2824        self.pong.render(&mut self.render_frame);
2825
2826        // Add debug info if enabled
2827        if self.config.debug {
2828            self.render_debug_info(dt);
2829        }
2830
2831        // Take any pending audio events
2832        let audio_events = self.pong.take_audio_events();
2833
2834        // Collect JS actions (e.g., download request, fullscreen toggle)
2835        let mut actions = Vec::new();
2836        if self.pong.download_requested {
2837            actions.push(JsAction::DownloadAiModel);
2838            self.pong.download_requested = false; // Consume the flag
2839        }
2840        if self.pong.fullscreen_requested {
2841            if self.pong.is_fullscreen {
2842                actions.push(JsAction::EnterFullscreen);
2843            } else {
2844                actions.push(JsAction::ExitFullscreen);
2845            }
2846            self.pong.fullscreen_requested = false; // Consume the flag
2847        }
2848
2849        // End trace frame (no state hash for now - can add deterministic hashing later)
2850        let _ = self.tracer.end_frame(None);
2851
2852        // Build frame output with optional debug info
2853        let output = FrameOutput {
2854            commands: self.render_frame.commands.clone(),
2855            audio_events,
2856            actions,
2857            debug_info: if self.config.debug {
2858                let stats = self.tracer.stats();
2859                Some(DebugInfo {
2860                    dt_ms: dt * 1000.0,
2861                    fps: self.timer.average_fps(),
2862                    frame_count: self.frame_count,
2863                    input_summary: String::new(),
2864                    game_mode: self.pong.game_mode().name().to_string(),
2865                    speed_multiplier: self.pong.speed_multiplier().value(),
2866                    left_paddle_y: self.pong.left_paddle_y(),
2867                    right_paddle_y: self.pong.right_paddle_y(),
2868                    ball_x: self.pong.ball_x(),
2869                    ball_y: self.pong.ball_y(),
2870                    trace_buffer_usage: Some(format!(
2871                        "{}/{}",
2872                        stats.buffer_len, stats.buffer_capacity
2873                    )),
2874                    trace_inputs: Some(stats.total_inputs),
2875                    trace_dropped: Some(stats.frames_dropped),
2876                })
2877            } else {
2878                None
2879            },
2880        };
2881        self.frame_count += 1;
2882
2883        // Serialize and return
2884        serde_json::to_string(&output).unwrap_or_else(|_| r#"{"commands":[]}"#.to_string())
2885    }
2886
2887    /// Handles canvas resize.
2888    ///
2889    /// # Arguments
2890    ///
2891    /// * `width` - New canvas width in pixels
2892    /// * `height` - New canvas height in pixels
2893    #[wasm_bindgen]
2894    pub fn resize(&mut self, width: u32, height: u32) {
2895        self.config.width = width;
2896        self.config.height = height;
2897        self.pong.resize(width, height);
2898    }
2899
2900    /// Returns the current configuration as JSON.
2901    #[wasm_bindgen(js_name = "getConfig")]
2902    #[must_use]
2903    pub fn get_config(&self) -> String {
2904        self.config.to_json().unwrap_or_else(|_| "{}".to_string())
2905    }
2906
2907    /// Returns current debug statistics as JSON.
2908    #[wasm_bindgen(js_name = "getStats")]
2909    #[must_use]
2910    pub fn get_stats(&self) -> String {
2911        let stats = serde_json::json!({
2912            "fps": self.timer.average_fps(),
2913            "frame_count": self.timer.frame_count(),
2914            "total_time": self.timer.total_time(),
2915        });
2916        stats.to_string()
2917    }
2918
2919    /// Resets the timer (useful when tab becomes visible again).
2920    #[wasm_bindgen(js_name = "resetTimer")]
2921    pub fn reset_timer(&mut self) {
2922        self.timer.reset();
2923    }
2924
2925    /// Returns the AI model as JSON string for download.
2926    #[wasm_bindgen(js_name = "getAiModel")]
2927    #[must_use]
2928    pub fn get_ai_model(&self) -> String {
2929        self.pong.export_ai_model()
2930    }
2931
2932    /// Returns AI model metadata and current state as JSON.
2933    #[wasm_bindgen(js_name = "getAiInfo")]
2934    #[must_use]
2935    pub fn get_ai_info(&self) -> String {
2936        self.pong.ai_info()
2937    }
2938
2939    /// Sets the AI difficulty level (0-9).
2940    #[wasm_bindgen(js_name = "setAiDifficulty")]
2941    pub fn set_ai_difficulty(&mut self, level: u8) {
2942        self.pong.set_ai_difficulty(level);
2943    }
2944
2945    /// Gets the current AI difficulty level.
2946    #[wasm_bindgen(js_name = "getAiDifficulty")]
2947    #[must_use]
2948    pub fn get_ai_difficulty(&self) -> u8 {
2949        self.pong.ai_difficulty()
2950    }
2951
2952    /// Sets the speed multiplier (1, 5, 10, 50, 100, 1000).
2953    #[wasm_bindgen(js_name = "setSpeed")]
2954    #[allow(clippy::match_same_arms)] // Explicit default case is clearer
2955    pub fn set_speed(&mut self, speed: u32) {
2956        self.pong.set_speed_multiplier(match speed {
2957            5 => SpeedMultiplier::Fast5x,
2958            10 => SpeedMultiplier::Fast10x,
2959            50 => SpeedMultiplier::Fast50x,
2960            100 => SpeedMultiplier::Fast100x,
2961            1000 => SpeedMultiplier::Fast1000x,
2962            _ => SpeedMultiplier::Normal, // 1 and any other value defaults to Normal
2963        });
2964    }
2965
2966    /// Gets the current speed multiplier value.
2967    #[wasm_bindgen(js_name = "getSpeed")]
2968    #[must_use]
2969    #[allow(clippy::missing_const_for_fn)] // wasm_bindgen not compatible
2970    pub fn get_speed(&self) -> u32 {
2971        self.pong.speed_multiplier().value()
2972    }
2973
2974    /// Sets the game mode ("demo", "1p", "2p").
2975    #[wasm_bindgen(js_name = "setGameMode")]
2976    #[allow(clippy::match_same_arms)] // Explicit default case is clearer
2977    pub fn set_game_mode(&mut self, mode: &str) {
2978        self.pong.set_game_mode(match mode.to_lowercase().as_str() {
2979            "demo" => GameMode::Demo,
2980            "2p" | "two" | "twoplayer" => GameMode::TwoPlayer,
2981            _ => GameMode::SinglePlayer, // "1p", "single", "singleplayer" and any other value
2982        });
2983    }
2984
2985    /// Gets the current game mode as string.
2986    #[wasm_bindgen(js_name = "getGameMode")]
2987    #[must_use]
2988    pub fn get_game_mode(&self) -> String {
2989        self.pong.game_mode().short_label().to_string()
2990    }
2991
2992    fn render_debug_info(&mut self, dt: f64) {
2993        let fps = if dt > 0.0 { 1.0 / dt } else { 0.0 };
2994        let debug_text = format!("FPS: {:.0} | Frame: {}", fps, self.timer.frame_count());
2995
2996        self.render_frame.fill_text_aligned(
2997            &debug_text,
2998            10.0,
2999            self.config.height as f32 - 10.0,
3000            "14px monospace",
3001            Color::GREEN,
3002            TextAlign::Left,
3003            TextBaseline::Bottom,
3004        );
3005    }
3006}
3007
3008// Non-wasm methods for testing
3009impl WebPlatform {
3010    /// Creates a platform without wasm-bindgen (for testing).
3011    #[must_use]
3012    pub fn new_for_test(config: WebConfig) -> Self {
3013        let pong = PongGame::new(config.width as f32, config.height as f32, config.ai_enabled);
3014        let tracer = if config.debug {
3015            GameTracer::debug()
3016        } else {
3017            GameTracer::new(TracerConfig::default())
3018        };
3019
3020        Self {
3021            config,
3022            timer: FrameTimer::new(),
3023            input: InputState::new(),
3024            render_frame: RenderFrame::with_capacity(100),
3025            game: None,
3026            pong,
3027            frame_count: 0,
3028            canvas_offset_x: 0.0,
3029            canvas_offset_y: 0.0,
3030            tracer,
3031        }
3032    }
3033
3034    /// Returns a reference to the input state (for testing).
3035    #[must_use]
3036    pub const fn input(&self) -> &InputState {
3037        &self.input
3038    }
3039
3040    /// Returns a mutable reference to the input state (for testing).
3041    #[allow(clippy::missing_const_for_fn)] // const fn with mutable references not yet stable
3042    pub fn input_mut(&mut self) -> &mut InputState {
3043        &mut self.input
3044    }
3045
3046    /// Returns a reference to the frame timer (for testing).
3047    #[must_use]
3048    pub const fn timer(&self) -> &FrameTimer {
3049        &self.timer
3050    }
3051
3052    /// Returns a reference to the Pong game (for testing).
3053    #[must_use]
3054    pub const fn pong(&self) -> &PongGame {
3055        &self.pong
3056    }
3057
3058    /// Returns a reference to the game tracer (for testing).
3059    #[must_use]
3060    pub const fn tracer(&self) -> &GameTracer {
3061        &self.tracer
3062    }
3063
3064    /// Returns a reference to the config (for testing).
3065    #[must_use]
3066    pub const fn config(&self) -> &WebConfig {
3067        &self.config
3068    }
3069}
3070
3071#[cfg(test)]
3072#[allow(
3073    clippy::unwrap_used,
3074    clippy::expect_used,
3075    clippy::panic,
3076    clippy::float_cmp,
3077    clippy::manual_range_contains,
3078    clippy::cast_lossless,
3079    clippy::suboptimal_flops,
3080    clippy::uninlined_format_args
3081)]
3082mod tests {
3083    use super::*;
3084
3085    #[test]
3086    fn test_web_config_default() {
3087        let config = WebConfig::default();
3088        assert_eq!(config.width, 800);
3089        assert_eq!(config.height, 600);
3090        assert_eq!(config.target_fps, 60);
3091        assert!(!config.debug);
3092    }
3093
3094    #[test]
3095    fn test_web_config_new() {
3096        let config = WebConfig::new(1920, 1080);
3097        assert_eq!(config.width, 1920);
3098        assert_eq!(config.height, 1080);
3099        assert_eq!(config.target_fps, 60);
3100    }
3101
3102    #[test]
3103    fn test_web_config_from_json() {
3104        let json = r#"{"width":1024,"height":768,"target_fps":30,"debug":true}"#;
3105        let config = WebConfig::from_json(json).unwrap();
3106        assert_eq!(config.width, 1024);
3107        assert_eq!(config.height, 768);
3108        assert_eq!(config.target_fps, 30);
3109        assert!(config.debug);
3110    }
3111
3112    #[test]
3113    fn test_web_config_from_json_invalid() {
3114        let result = WebConfig::from_json("not valid json");
3115        assert!(result.is_err());
3116    }
3117
3118    #[test]
3119    fn test_web_config_to_json() {
3120        let config = WebConfig::new(800, 600);
3121        let json = config.to_json().unwrap();
3122        assert!(json.contains("800"));
3123        assert!(json.contains("600"));
3124    }
3125
3126    #[test]
3127    fn test_web_platform_error_display() {
3128        let err = WebPlatformError::InvalidConfig("bad config".to_string());
3129        assert_eq!(err.to_string(), "Invalid config: bad config");
3130
3131        let err = WebPlatformError::InputError("input failed".to_string());
3132        assert_eq!(err.to_string(), "Input error: input failed");
3133
3134        let err = WebPlatformError::RenderError("render failed".to_string());
3135        assert_eq!(err.to_string(), "Render error: render failed");
3136    }
3137
3138    #[test]
3139    fn test_web_platform_error_from_input_error() {
3140        let input_err = InputTranslationError::InvalidJson("test".to_string());
3141        let platform_err: WebPlatformError = input_err.into();
3142        assert!(matches!(platform_err, WebPlatformError::InputError(_)));
3143    }
3144
3145    #[test]
3146    fn test_pong_game_new() {
3147        let game = PongGame::new(800.0, 600.0, false);
3148        assert!((game.width - 800.0).abs() < f32::EPSILON);
3149        assert!((game.height - 600.0).abs() < f32::EPSILON);
3150        assert_eq!(game.left_score(), 0);
3151        assert_eq!(game.right_score(), 0);
3152    }
3153
3154    #[test]
3155    fn test_pong_game_default() {
3156        let game = PongGame::default();
3157        assert!((game.width - 800.0).abs() < f32::EPSILON);
3158        assert!((game.height - 600.0).abs() < f32::EPSILON);
3159    }
3160
3161    #[test]
3162    fn test_pong_game_ball_position() {
3163        let game = PongGame::new(800.0, 600.0, false);
3164        let (x, y) = game.ball_position();
3165        assert!((x - 400.0).abs() < f32::EPSILON);
3166        assert!((y - 300.0).abs() < f32::EPSILON);
3167    }
3168
3169    #[test]
3170    fn test_pong_game_update_no_input() {
3171        let mut game = PongGame::new(800.0, 600.0, false);
3172        game.set_state(GameState::Playing); // Start game for test
3173        let input = InputState::new();
3174        let initial_ball_x = game.ball_x;
3175
3176        game.update(&input, 0.016);
3177
3178        // Ball should have moved
3179        assert!((game.ball_x - initial_ball_x).abs() > 1.0);
3180    }
3181
3182    #[test]
3183    fn test_pong_game_paddle_movement() {
3184        let mut game = PongGame::new(800.0, 600.0, false);
3185        game.set_state(GameState::Playing); // Start game for test
3186        game.game_mode = GameMode::SinglePlayer; // Human controls RIGHT paddle
3187        let mut input = InputState::new();
3188        let initial_y = game.right_paddle_y;
3189
3190        // Press Up arrow key (P1 controls right paddle)
3191        input.set_key_pressed(jugar_input::KeyCode::Up, true);
3192        game.update(&input, 0.1);
3193
3194        // Right paddle should have moved up
3195        assert!(game.right_paddle_y < initial_y);
3196    }
3197
3198    #[test]
3199    fn test_pong_game_paddle_clamping() {
3200        let mut game = PongGame::new(800.0, 600.0, false);
3201        game.set_state(GameState::Playing); // Start game for test
3202        game.game_mode = GameMode::SinglePlayer; // Human controls left paddle
3203        let mut input = InputState::new();
3204
3205        // Press W key many times to hit top
3206        input.set_key_pressed(jugar_input::KeyCode::Letter('W'), true);
3207        for _ in 0..100 {
3208            game.update(&input, 0.1);
3209        }
3210
3211        // Paddle should be clamped to screen
3212        let half_paddle = game.paddle_height / 2.0;
3213        assert!(game.left_paddle_y >= half_paddle);
3214    }
3215
3216    #[test]
3217    fn test_pong_game_render() {
3218        let mut game = PongGame::new(800.0, 600.0, false);
3219        let mut frame = RenderFrame::new();
3220
3221        game.render(&mut frame);
3222
3223        // Should have several commands (clear, center line dashes, 2 paddles, ball, 2 scores)
3224        assert!(frame.len() > 5);
3225    }
3226
3227    #[test]
3228    fn test_pong_game_resize() {
3229        let mut game = PongGame::new(800.0, 600.0, false);
3230        game.resize(1600, 1200);
3231
3232        assert!((game.width - 1600.0).abs() < f32::EPSILON);
3233        assert!((game.height - 1200.0).abs() < f32::EPSILON);
3234    }
3235
3236    #[test]
3237    fn test_web_platform_new_for_test() {
3238        let config = WebConfig::default();
3239        let platform = WebPlatform::new_for_test(config);
3240
3241        assert_eq!(platform.config().width, 800);
3242        assert_eq!(platform.config().height, 600);
3243    }
3244
3245    #[test]
3246    fn test_web_platform_frame() {
3247        let config = WebConfig::default();
3248        let mut platform = WebPlatform::new_for_test(config);
3249
3250        // First frame
3251        let result = platform.frame(0.0, "[]");
3252        assert!(!result.is_empty());
3253        assert!(result.contains("Clear"));
3254
3255        // Second frame
3256        let result = platform.frame(16.667, "[]");
3257        assert!(!result.is_empty());
3258    }
3259
3260    #[test]
3261    fn test_web_platform_frame_with_input() {
3262        let config = WebConfig::default();
3263        let mut platform = WebPlatform::new_for_test(config);
3264
3265        let input_json = r#"[{"event_type":"KeyDown","timestamp":0,"data":{"key":"KeyW"}}]"#;
3266        let _ = platform.frame(0.0, input_json);
3267
3268        // Key should be registered
3269        assert!(platform
3270            .input()
3271            .is_key_pressed(jugar_input::KeyCode::Letter('W')));
3272    }
3273
3274    #[test]
3275    fn test_web_platform_resize() {
3276        let config = WebConfig::default();
3277        let mut platform = WebPlatform::new_for_test(config);
3278
3279        platform.resize(1920, 1080);
3280
3281        assert_eq!(platform.config().width, 1920);
3282        assert_eq!(platform.config().height, 1080);
3283    }
3284
3285    #[test]
3286    fn test_web_platform_get_config() {
3287        let config = WebConfig::default();
3288        let platform = WebPlatform::new_for_test(config);
3289
3290        let config_json = platform.get_config();
3291        assert!(config_json.contains("800"));
3292        assert!(config_json.contains("600"));
3293    }
3294
3295    #[test]
3296    fn test_web_platform_get_stats() {
3297        let config = WebConfig::default();
3298        let mut platform = WebPlatform::new_for_test(config);
3299
3300        // Run a few frames
3301        let _ = platform.frame(0.0, "[]");
3302        let _ = platform.frame(16.667, "[]");
3303        let _ = platform.frame(33.333, "[]");
3304
3305        let stats = platform.get_stats();
3306        assert!(stats.contains("fps"));
3307        assert!(stats.contains("frame_count"));
3308    }
3309
3310    #[test]
3311    fn test_web_platform_reset_timer() {
3312        let config = WebConfig::default();
3313        let mut platform = WebPlatform::new_for_test(config);
3314
3315        let _ = platform.frame(0.0, "[]");
3316        let _ = platform.frame(1000.0, "[]");
3317
3318        platform.reset_timer();
3319
3320        assert_eq!(platform.timer().frame_count(), 0);
3321    }
3322
3323    #[test]
3324    fn test_web_platform_debug_mode() {
3325        let config = WebConfig {
3326            debug: true,
3327            ..WebConfig::default()
3328        };
3329        let mut platform = WebPlatform::new_for_test(config);
3330
3331        let _ = platform.frame(0.0, "[]");
3332        let result = platform.frame(16.667, "[]");
3333
3334        // Debug text should be rendered
3335        assert!(result.contains("FPS"));
3336    }
3337
3338    #[test]
3339    fn test_web_platform_input_accessors() {
3340        let config = WebConfig::default();
3341        let mut platform = WebPlatform::new_for_test(config);
3342
3343        // Test input_mut
3344        platform
3345            .input_mut()
3346            .set_key_pressed(jugar_input::KeyCode::Space, true);
3347
3348        // Test input
3349        assert!(platform.input().is_key_pressed(jugar_input::KeyCode::Space));
3350    }
3351
3352    #[test]
3353    fn test_web_platform_pong_accessor() {
3354        let config = WebConfig::default();
3355        let platform = WebPlatform::new_for_test(config);
3356
3357        let pong = platform.pong();
3358        assert_eq!(pong.left_score(), 0);
3359        assert_eq!(pong.right_score(), 0);
3360    }
3361
3362    #[test]
3363    fn test_pong_ball_wall_collision_top() {
3364        let mut game = PongGame::new(800.0, 600.0, false);
3365        game.set_state(GameState::Playing); // Start game for test
3366        game.ball_y = 5.0; // Near top
3367        game.ball_vy = -100.0; // Moving up
3368
3369        let input = InputState::new();
3370        game.update(&input, 0.1);
3371
3372        // Ball should have bounced
3373        assert!(game.ball_vy > 0.0);
3374    }
3375
3376    #[test]
3377    fn test_pong_ball_wall_collision_bottom() {
3378        let mut game = PongGame::new(800.0, 600.0, false);
3379        game.set_state(GameState::Playing); // Start game for test
3380        game.ball_y = 595.0; // Near bottom
3381        game.ball_vy = 100.0; // Moving down
3382
3383        let input = InputState::new();
3384        game.update(&input, 0.1);
3385
3386        // Ball should have bounced
3387        assert!(game.ball_vy < 0.0);
3388    }
3389
3390    #[test]
3391    fn test_pong_scoring_right() {
3392        let mut game = PongGame::new(800.0, 600.0, false);
3393        game.set_state(GameState::Playing); // Start game for test
3394        game.ball_x = -10.0; // Past left edge
3395
3396        let input = InputState::new();
3397        game.update(&input, 0.016);
3398
3399        // Right player should score
3400        assert_eq!(game.right_score(), 1);
3401    }
3402
3403    #[test]
3404    fn test_pong_scoring_left() {
3405        let mut game = PongGame::new(800.0, 600.0, false);
3406        game.set_state(GameState::Playing); // Start game for test
3407        game.ball_x = 810.0; // Past right edge
3408
3409        let input = InputState::new();
3410        game.update(&input, 0.016);
3411
3412        // Left player should score
3413        assert_eq!(game.left_score(), 1);
3414    }
3415
3416    #[test]
3417    fn test_frame_output_serialization() {
3418        let output = FrameOutput {
3419            commands: vec![Canvas2DCommand::Clear {
3420                color: Color::BLACK,
3421            }],
3422            audio_events: vec![],
3423            actions: vec![],
3424            debug_info: None,
3425        };
3426
3427        let json = serde_json::to_string(&output).unwrap();
3428        assert!(json.contains("commands"));
3429        assert!(!json.contains("debug_info")); // Skipped when None
3430        assert!(!json.contains("audio_events")); // Skipped when empty
3431    }
3432
3433    #[test]
3434    fn test_frame_output_with_debug() {
3435        let output = FrameOutput {
3436            commands: vec![],
3437            audio_events: vec![],
3438            actions: vec![],
3439            debug_info: Some(DebugInfo {
3440                dt_ms: 16.667,
3441                fps: 60.0,
3442                frame_count: 100,
3443                input_summary: "W pressed".to_string(),
3444                game_mode: "SinglePlayer".to_string(),
3445                speed_multiplier: 1,
3446                left_paddle_y: 300.0,
3447                right_paddle_y: 300.0,
3448                ball_x: 400.0,
3449                ball_y: 300.0,
3450                trace_buffer_usage: Some("100/3600".to_string()),
3451                trace_inputs: Some(42),
3452                trace_dropped: Some(0),
3453            }),
3454        };
3455
3456        let json = serde_json::to_string(&output).unwrap();
3457        assert!(json.contains("debug_info"));
3458        assert!(json.contains("fps"));
3459    }
3460
3461    #[test]
3462    fn test_frame_output_with_audio() {
3463        let output = FrameOutput {
3464            commands: vec![],
3465            audio_events: vec![AudioEvent::GameStart { volume: 0.7 }],
3466            actions: vec![],
3467            debug_info: None,
3468        };
3469
3470        let json = serde_json::to_string(&output).unwrap();
3471        assert!(json.contains("audio_events"));
3472        assert!(json.contains("GameStart"));
3473    }
3474
3475    #[test]
3476    fn test_debug_info_serialization() {
3477        let info = DebugInfo {
3478            dt_ms: 16.667,
3479            fps: 60.0,
3480            frame_count: 42,
3481            input_summary: "test".to_string(),
3482            game_mode: "Demo".to_string(),
3483            speed_multiplier: 10,
3484            left_paddle_y: 300.0,
3485            right_paddle_y: 300.0,
3486            ball_x: 400.0,
3487            ball_y: 300.0,
3488            trace_buffer_usage: None,
3489            trace_inputs: None,
3490            trace_dropped: None,
3491        };
3492
3493        let json = serde_json::to_string(&info).unwrap();
3494        assert!(json.contains("16.667"));
3495        assert!(json.contains("60"));
3496        // Trace fields are skipped when None
3497        assert!(!json.contains("trace_buffer_usage"));
3498        assert!(json.contains("42"));
3499        assert!(json.contains("Demo"));
3500        assert!(json.contains("10"));
3501        assert!(json.contains("left_paddle_y"));
3502        assert!(json.contains("ball_x"));
3503    }
3504
3505    #[test]
3506    fn test_pong_right_paddle_controls() {
3507        let mut game = PongGame::new(800.0, 600.0, false);
3508        game.set_state(GameState::Playing); // Start game for test
3509        game.game_mode = GameMode::TwoPlayer; // Enable human control of right paddle
3510        let mut input = InputState::new();
3511        let initial_y = game.right_paddle_y;
3512
3513        // Press ArrowDown key
3514        input.set_key_pressed(jugar_input::KeyCode::Down, true);
3515        game.update(&input, 0.1);
3516
3517        // Right paddle should have moved down
3518        assert!(game.right_paddle_y > initial_y);
3519    }
3520
3521    #[test]
3522    fn test_pong_right_paddle_up() {
3523        let mut game = PongGame::new(800.0, 600.0, false);
3524        game.set_state(GameState::Playing); // Start game for test
3525        game.game_mode = GameMode::TwoPlayer; // Enable human control of right paddle
3526        let mut input = InputState::new();
3527        let initial_y = game.right_paddle_y;
3528
3529        // Press ArrowUp key
3530        input.set_key_pressed(jugar_input::KeyCode::Up, true);
3531        game.update(&input, 0.1);
3532
3533        // Right paddle should have moved up
3534        assert!(game.right_paddle_y < initial_y);
3535    }
3536
3537    #[test]
3538    fn test_pong_right_paddle_down() {
3539        let mut game = PongGame::new(800.0, 600.0, false);
3540        game.set_state(GameState::Playing); // Start game for test
3541        game.game_mode = GameMode::SinglePlayer; // Human controls RIGHT paddle
3542        let mut input = InputState::new();
3543        let initial_y = game.right_paddle_y;
3544
3545        // Press Down arrow key (P1 controls right paddle)
3546        input.set_key_pressed(jugar_input::KeyCode::Down, true);
3547        game.update(&input, 0.1);
3548
3549        // Right paddle should have moved down
3550        assert!(game.right_paddle_y > initial_y);
3551    }
3552
3553    #[test]
3554    fn test_pong_paddle_collision_left() {
3555        let mut game = PongGame::new(800.0, 600.0, false);
3556        game.set_state(GameState::Playing); // Start game for test
3557                                            // Position ball to hit left paddle (paddle at x=20..35)
3558                                            // Ball radius is 10, so ball_x - radius needs to be in (20, 35)
3559                                            // ball_x = 46 means left edge at 36, after moving left 1.6px (vx=-100, dt=0.016)
3560                                            // left edge becomes 36-1.6=34.4, which is in (20, 35)
3561        game.ball_x = 46.0;
3562        game.ball_y = game.left_paddle_y;
3563        game.ball_vx = -100.0; // Slower velocity so we stay in collision zone
3564
3565        let input = InputState::new();
3566        game.update(&input, 0.016);
3567
3568        // Ball should have bounced (velocity reversed to positive)
3569        assert!(game.ball_vx > 0.0);
3570    }
3571
3572    #[test]
3573    fn test_pong_paddle_collision_right() {
3574        let mut game = PongGame::new(800.0, 600.0, false);
3575        game.set_state(GameState::Playing); // Start game for test
3576                                            // Position ball to hit right paddle (paddle at x=765..780)
3577                                            // Ball radius is 10, so ball_x + radius needs to be in (765, 780)
3578                                            // ball_x = 758 means right edge at 768, which is in (765, 780)
3579        game.ball_x = 758.0;
3580        game.ball_y = game.right_paddle_y;
3581        game.ball_vx = 200.0;
3582
3583        let input = InputState::new();
3584        game.update(&input, 0.016);
3585
3586        // Ball should have bounced (velocity reversed to negative)
3587        assert!(game.ball_vx < 0.0);
3588    }
3589
3590    #[test]
3591    fn test_pong_resize_scales_positions() {
3592        let mut game = PongGame::new(800.0, 600.0, false);
3593        // Set ball to center
3594        game.ball_x = 400.0;
3595        game.ball_y = 300.0;
3596
3597        game.resize(1600, 1200);
3598
3599        // Ball should be scaled to new center
3600        assert!((game.ball_x - 800.0).abs() < 1.0);
3601        assert!((game.ball_y - 600.0).abs() < 1.0);
3602    }
3603
3604    // =========================================================================
3605    // Game State Tests
3606    // =========================================================================
3607
3608    #[test]
3609    fn test_game_state_default_is_playing() {
3610        // Game starts directly in Playing state (Demo mode attract)
3611        let game = PongGame::new(800.0, 600.0, false);
3612        assert_eq!(game.state(), GameState::Playing);
3613    }
3614
3615    #[test]
3616    fn test_game_state_start_from_menu() {
3617        // If game is set to Menu state, start() transitions to Playing
3618        let mut game = PongGame::new(800.0, 600.0, false);
3619        game.set_state(GameState::Menu);
3620        assert_eq!(game.state(), GameState::Menu);
3621
3622        game.start();
3623
3624        assert_eq!(game.state(), GameState::Playing);
3625    }
3626
3627    #[test]
3628    fn test_game_state_pause_transitions() {
3629        let mut game = PongGame::new(800.0, 600.0, false);
3630        game.set_state(GameState::Playing);
3631
3632        // Press Escape to pause
3633        let mut input = InputState::new();
3634        input.set_key_pressed(jugar_input::KeyCode::Escape, true);
3635        game.update(&input, 0.016);
3636
3637        assert_eq!(game.state(), GameState::Paused);
3638    }
3639
3640    #[test]
3641    fn test_game_state_unpause_with_space() {
3642        let mut game = PongGame::new(800.0, 600.0, false);
3643        game.set_state(GameState::Paused);
3644
3645        // First update with space not pressed (to set prev state)
3646        let input = InputState::new();
3647        game.update(&input, 0.016);
3648
3649        // Now press space
3650        let mut input = InputState::new();
3651        input.set_key_pressed(jugar_input::KeyCode::Space, true);
3652        game.update(&input, 0.016);
3653
3654        assert_eq!(game.state(), GameState::Playing);
3655    }
3656
3657    #[test]
3658    fn test_game_state_menu_space_starts_game() {
3659        // When in Menu state, pressing Space starts the game
3660        let mut game = PongGame::new(800.0, 600.0, false);
3661        game.set_state(GameState::Menu); // Set to Menu state for this test
3662        assert_eq!(game.state(), GameState::Menu);
3663
3664        // First update with space not pressed (to set prev state)
3665        let input = InputState::new();
3666        game.update(&input, 0.016);
3667
3668        // Now press space
3669        let mut input = InputState::new();
3670        input.set_key_pressed(jugar_input::KeyCode::Space, true);
3671        game.update(&input, 0.016);
3672
3673        assert_eq!(game.state(), GameState::Playing);
3674    }
3675
3676    #[test]
3677    fn test_game_state_game_over_on_winning_score() {
3678        let mut game = PongGame::new(800.0, 600.0, false);
3679        game.set_state(GameState::Playing);
3680        game.left_score = 10; // One away from winning
3681        game.ball_x = 810.0; // Past right edge - left player scores
3682
3683        let input = InputState::new();
3684        game.update(&input, 0.016);
3685
3686        assert_eq!(game.left_score(), 11);
3687        assert_eq!(game.state(), GameState::GameOver);
3688    }
3689
3690    #[test]
3691    fn test_game_state_reset_from_game_over() {
3692        let mut game = PongGame::new(800.0, 600.0, false);
3693        game.set_state(GameState::GameOver);
3694        game.left_score = 11;
3695        game.right_score = 5;
3696
3697        // First update with space not pressed
3698        let input = InputState::new();
3699        game.update(&input, 0.016);
3700
3701        // Now press space to restart
3702        let mut input = InputState::new();
3703        input.set_key_pressed(jugar_input::KeyCode::Space, true);
3704        game.update(&input, 0.016);
3705
3706        assert_eq!(game.state(), GameState::Playing);
3707        assert_eq!(game.left_score(), 0);
3708        assert_eq!(game.right_score(), 0);
3709    }
3710
3711    #[test]
3712    fn test_game_state_no_update_when_paused() {
3713        let mut game = PongGame::new(800.0, 600.0, false);
3714        game.set_state(GameState::Paused);
3715        let initial_ball_x = game.ball_x;
3716
3717        let input = InputState::new();
3718        game.update(&input, 0.016);
3719
3720        // Ball should not have moved
3721        assert!((game.ball_x - initial_ball_x).abs() < 0.001);
3722    }
3723
3724    #[test]
3725    fn test_game_state_reset_game() {
3726        let mut game = PongGame::new(800.0, 600.0, false);
3727        game.set_state(GameState::Playing);
3728        game.left_score = 5;
3729        game.right_score = 3;
3730        game.ball_x = 100.0;
3731
3732        game.reset_game();
3733
3734        assert_eq!(game.left_score(), 0);
3735        assert_eq!(game.right_score(), 0);
3736        assert!((game.ball_x - 400.0).abs() < 1.0); // Back to center
3737    }
3738
3739    // =========================================================================
3740    // Rally Counter Tests
3741    // =========================================================================
3742
3743    #[test]
3744    fn test_rally_counter_starts_at_zero() {
3745        let game = PongGame::new(800.0, 600.0, false);
3746        assert_eq!(game.rally_count(), 0);
3747    }
3748
3749    #[test]
3750    fn test_rally_counter_increments_on_paddle_hit() {
3751        let mut game = PongGame::new(800.0, 600.0, false);
3752        game.set_state(GameState::Playing);
3753        assert_eq!(game.rally_count(), 0);
3754
3755        // Position ball to hit left paddle
3756        game.ball_x = 46.0;
3757        game.ball_y = game.left_paddle_y;
3758        game.ball_vx = -100.0;
3759
3760        let input = InputState::new();
3761        game.update(&input, 0.016);
3762
3763        // Rally should have incremented
3764        assert_eq!(game.rally_count(), 1);
3765    }
3766
3767    #[test]
3768    fn test_rally_counter_resets_on_scoring() {
3769        let mut game = PongGame::new(800.0, 600.0, false);
3770        game.set_state(GameState::Playing);
3771        game.rally_count = 5; // Simulate some rallies
3772        game.ball_x = -10.0; // Ball past left edge - right player scores
3773
3774        let input = InputState::new();
3775        game.update(&input, 0.016);
3776
3777        // Rally should have reset to 0
3778        assert_eq!(game.rally_count(), 0);
3779    }
3780
3781    #[test]
3782    fn test_rally_counter_resets_on_game_reset() {
3783        let mut game = PongGame::new(800.0, 600.0, false);
3784        game.rally_count = 10;
3785
3786        game.reset_game();
3787
3788        assert_eq!(game.rally_count(), 0);
3789    }
3790
3791    // =========================================================================
3792    // High Score Tests
3793    // =========================================================================
3794
3795    #[test]
3796    fn test_high_score_starts_at_zero() {
3797        let game = PongGame::new(800.0, 600.0, false);
3798        assert_eq!(game.high_score(), 0);
3799    }
3800
3801    #[test]
3802    fn test_high_score_updates_when_rally_ends() {
3803        let mut game = PongGame::new(800.0, 600.0, false);
3804        game.set_state(GameState::Playing);
3805        game.rally_count = 8;
3806        game.ball_x = -10.0; // Ball past left edge - triggers scoring
3807
3808        let input = InputState::new();
3809        game.update(&input, 0.016);
3810
3811        // High score should be 8 (the rally count before reset)
3812        assert_eq!(game.high_score(), 8);
3813    }
3814
3815    #[test]
3816    fn test_high_score_only_updates_if_higher() {
3817        let mut game = PongGame::new(800.0, 600.0, false);
3818        game.set_state(GameState::Playing);
3819        game.high_score = 10;
3820        game.rally_count = 5;
3821        game.ball_x = -10.0; // Ball past left edge
3822
3823        let input = InputState::new();
3824        game.update(&input, 0.016);
3825
3826        // High score should still be 10 (higher than 5)
3827        assert_eq!(game.high_score(), 10);
3828    }
3829
3830    #[test]
3831    fn test_high_score_persists_across_reset() {
3832        let mut game = PongGame::new(800.0, 600.0, false);
3833        game.high_score = 15;
3834
3835        game.reset_game();
3836
3837        // High score should persist
3838        assert_eq!(game.high_score(), 15);
3839    }
3840
3841    #[test]
3842    fn test_high_score_updates_on_both_sides_scoring() {
3843        let mut game = PongGame::new(800.0, 600.0, false);
3844        game.set_state(GameState::Playing);
3845
3846        // Left player misses
3847        game.rally_count = 7;
3848        game.ball_x = -10.0;
3849        let input = InputState::new();
3850        game.update(&input, 0.016);
3851        assert_eq!(game.high_score(), 7);
3852
3853        // Reset ball and rally
3854        game.ball_x = 400.0;
3855
3856        // Now right player misses with a higher rally
3857        game.rally_count = 12;
3858        game.ball_x = 810.0;
3859        game.update(&input, 0.016);
3860        assert_eq!(game.high_score(), 12);
3861    }
3862
3863    // =========================================================================
3864    // Background Animation Tests
3865    // =========================================================================
3866
3867    #[test]
3868    fn test_bg_time_starts_at_zero() {
3869        let game = PongGame::new(800.0, 600.0, false);
3870        assert!((game.bg_time - 0.0).abs() < f32::EPSILON);
3871    }
3872
3873    #[test]
3874    fn test_bg_time_increments_during_gameplay() {
3875        let mut game = PongGame::new(800.0, 600.0, false);
3876        game.set_state(GameState::Playing);
3877        assert!((game.bg_time - 0.0).abs() < f32::EPSILON);
3878
3879        let input = InputState::new();
3880        game.update(&input, 0.5);
3881
3882        assert!((game.bg_time - 0.5).abs() < 0.001);
3883    }
3884
3885    // =========================================================================
3886    // HUD Button Tests for Coverage
3887    // =========================================================================
3888
3889    #[test]
3890    fn test_hud_buttons_default() {
3891        let hud = HudButtons::default();
3892        // All buttons should be at default (0, 0, 0, 0) position
3893        assert_eq!(hud.mode_demo.x, 0.0);
3894        assert_eq!(hud.mode_1p.x, 0.0);
3895        assert_eq!(hud.mode_2p.x, 0.0);
3896        assert_eq!(hud.speed_1x.x, 0.0);
3897    }
3898
3899    #[test]
3900    fn test_hud_buttons_calculate() {
3901        let hud = HudButtons::calculate(800.0, 600.0);
3902        // Mode buttons should be positioned
3903        assert!(hud.mode_demo.x > 0.0);
3904        assert!(hud.mode_1p.x > hud.mode_demo.x);
3905        assert!(hud.mode_2p.x > hud.mode_1p.x);
3906        // Speed buttons should be positioned
3907        assert!(hud.speed_1x.x > 0.0);
3908    }
3909
3910    #[test]
3911    fn test_hud_buttons_hit_test() {
3912        let hud = HudButtons::calculate(800.0, 600.0);
3913
3914        // Test hit detection using contains method
3915        let test_x = hud.mode_demo.x + hud.mode_demo.width / 2.0;
3916        let test_y = hud.mode_demo.y + hud.mode_demo.height / 2.0;
3917
3918        // Point inside button
3919        assert!(hud.mode_demo.contains(test_x, test_y));
3920    }
3921
3922    #[test]
3923    fn test_button_rect_new() {
3924        let btn = ButtonRect::new(10.0, 20.0, 100.0, 50.0);
3925        assert_eq!(btn.x, 10.0);
3926        assert_eq!(btn.y, 20.0);
3927        assert_eq!(btn.width, 100.0);
3928        assert_eq!(btn.height, 50.0);
3929    }
3930
3931    #[test]
3932    fn test_button_rect_contains() {
3933        let btn = ButtonRect::new(10.0, 10.0, 100.0, 50.0);
3934        // Inside
3935        assert!(btn.contains(50.0, 30.0));
3936        // Outside left
3937        assert!(!btn.contains(5.0, 30.0));
3938        // Outside right
3939        assert!(!btn.contains(120.0, 30.0));
3940        // Outside top
3941        assert!(!btn.contains(50.0, 5.0));
3942        // Outside bottom
3943        assert!(!btn.contains(50.0, 65.0));
3944    }
3945
3946    #[test]
3947    fn test_web_platform_mode_toggle_via_d_key() {
3948        let config = WebConfig::default();
3949        let mut platform = WebPlatform::new_for_test(config);
3950
3951        // Get initial mode
3952        let _ = platform.frame(0.0, "[]");
3953
3954        // Press D to toggle demo mode
3955        let d_down = r#"[{"event_type":"KeyDown","timestamp":100,"data":{"key":"KeyD"}}]"#;
3956        let result = platform.frame(100.0, d_down);
3957        assert!(result.contains("Demo") || result.contains("SinglePlayer"));
3958
3959        // Release D
3960        let d_up = r#"[{"event_type":"KeyUp","timestamp":116,"data":{"key":"KeyD"}}]"#;
3961        let _ = platform.frame(116.0, d_up);
3962    }
3963
3964    #[test]
3965    fn test_web_platform_mode_cycle_via_m_key() {
3966        let config = WebConfig::default();
3967        let mut platform = WebPlatform::new_for_test(config);
3968
3969        let _ = platform.frame(0.0, "[]");
3970
3971        // Press M to cycle modes
3972        let m_down = r#"[{"event_type":"KeyDown","timestamp":100,"data":{"key":"KeyM"}}]"#;
3973        let _ = platform.frame(100.0, m_down);
3974
3975        let m_up = r#"[{"event_type":"KeyUp","timestamp":116,"data":{"key":"KeyM"}}]"#;
3976        let _ = platform.frame(116.0, m_up);
3977    }
3978
3979    #[test]
3980    fn test_web_platform_speed_keys_1_through_6() {
3981        let config = WebConfig::default();
3982        let mut platform = WebPlatform::new_for_test(config);
3983
3984        let _ = platform.frame(0.0, "[]");
3985
3986        // Press digit 3 for 10x speed
3987        let key_3 = r#"[{"event_type":"KeyDown","timestamp":100,"data":{"key":"Digit3"}}]"#;
3988        let _ = platform.frame(100.0, key_3);
3989
3990        // Press digit 6 for 1000x speed
3991        let key_6 = r#"[{"event_type":"KeyDown","timestamp":200,"data":{"key":"Digit6"}}]"#;
3992        let _ = platform.frame(200.0, key_6);
3993
3994        // Press digit 1 for 1x speed
3995        let key_1 = r#"[{"event_type":"KeyDown","timestamp":300,"data":{"key":"Digit1"}}]"#;
3996        let _ = platform.frame(300.0, key_1);
3997    }
3998
3999    #[test]
4000    fn test_web_platform_fullscreen_via_f_key() {
4001        let config = WebConfig::default();
4002        let mut platform = WebPlatform::new_for_test(config);
4003
4004        let _ = platform.frame(0.0, "[]");
4005
4006        // Press F to request fullscreen
4007        let key_f = r#"[{"event_type":"KeyDown","timestamp":100,"data":{"key":"KeyF"}}]"#;
4008        let output = platform.frame(100.0, key_f);
4009
4010        // Verify EnterFullscreen action is emitted
4011        assert!(
4012            output.contains("EnterFullscreen"),
4013            "F key should trigger EnterFullscreen action"
4014        );
4015
4016        // Release F
4017        let key_f_up = r#"[{"event_type":"KeyUp","timestamp":116,"data":{"key":"KeyF"}}]"#;
4018        let _ = platform.frame(116.0, key_f_up);
4019
4020        // Press F again to exit fullscreen
4021        let output = platform.frame(200.0, key_f);
4022        assert!(
4023            output.contains("ExitFullscreen"),
4024            "F key should toggle to ExitFullscreen action"
4025        );
4026    }
4027
4028    #[test]
4029    fn test_web_platform_fullscreen_via_f11_key() {
4030        let config = WebConfig::default();
4031        let mut platform = WebPlatform::new_for_test(config);
4032
4033        let _ = platform.frame(0.0, "[]");
4034
4035        // Press F11 to request fullscreen
4036        let key_f11 = r#"[{"event_type":"KeyDown","timestamp":100,"data":{"key":"F11"}}]"#;
4037        let output = platform.frame(100.0, key_f11);
4038
4039        // Verify EnterFullscreen action is emitted
4040        assert!(
4041            output.contains("EnterFullscreen"),
4042            "F11 key should trigger EnterFullscreen action"
4043        );
4044    }
4045
4046    #[test]
4047    fn test_web_platform_pause_resume_via_escape() {
4048        let config = WebConfig::default();
4049        let mut platform = WebPlatform::new_for_test(config);
4050
4051        // Start game
4052        let space_down = r#"[{"event_type":"KeyDown","timestamp":0,"data":{"key":"Space"}}]"#;
4053        let _ = platform.frame(0.0, space_down);
4054        let space_up = r#"[{"event_type":"KeyUp","timestamp":16,"data":{"key":"Space"}}]"#;
4055        let _ = platform.frame(16.0, space_up);
4056        let _ = platform.frame(33.0, "[]");
4057
4058        // Press Escape to pause
4059        let esc_down = r#"[{"event_type":"KeyDown","timestamp":100,"data":{"key":"Escape"}}]"#;
4060        let result = platform.frame(100.0, esc_down);
4061        assert!(result.contains("PAUSED") || result.contains("commands"));
4062
4063        // Release and re-press to resume
4064        let esc_up = r#"[{"event_type":"KeyUp","timestamp":116,"data":{"key":"Escape"}}]"#;
4065        let _ = platform.frame(116.0, esc_up);
4066        let _ = platform.frame(200.0, esc_down);
4067    }
4068
4069    #[test]
4070    fn test_web_platform_info_panel_toggle() {
4071        let config = WebConfig::default();
4072        let mut platform = WebPlatform::new_for_test(config);
4073
4074        let _ = platform.frame(0.0, "[]");
4075
4076        // Press I to toggle info panel
4077        let i_down = r#"[{"event_type":"KeyDown","timestamp":100,"data":{"key":"KeyI"}}]"#;
4078        let result = platform.frame(100.0, i_down);
4079        assert!(result.contains("Pong AI") || result.contains("commands"));
4080    }
4081
4082    #[test]
4083    fn test_web_platform_mouse_click_hud() {
4084        let config = WebConfig::default();
4085        let mut platform = WebPlatform::new_for_test(config);
4086
4087        let _ = platform.frame(0.0, "[]");
4088
4089        // Click on approximate HUD area
4090        let click =
4091            r#"[{"event_type":"MouseDown","timestamp":100,"data":{"button":0,"x":100,"y":20}}]"#;
4092        let _ = platform.frame(100.0, click);
4093
4094        let release =
4095            r#"[{"event_type":"MouseUp","timestamp":116,"data":{"button":0,"x":100,"y":20}}]"#;
4096        let _ = platform.frame(116.0, release);
4097    }
4098
4099    #[test]
4100    fn test_web_platform_download_button_click() {
4101        let config = WebConfig::default();
4102        let mut platform = WebPlatform::new_for_test(config);
4103
4104        let _ = platform.frame(0.0, "[]");
4105
4106        // Click on download button area (bottom left footer)
4107        let click =
4108            r#"[{"event_type":"MouseDown","timestamp":100,"data":{"button":0,"x":70,"y":569}}]"#;
4109        let result = platform.frame(100.0, click);
4110        // Should have download action in response
4111        assert!(result.contains("DownloadAiModel") || result.contains("commands"));
4112    }
4113
4114    #[test]
4115    fn test_web_platform_get_ai_model() {
4116        let config = WebConfig::default();
4117        let platform = WebPlatform::new_for_test(config);
4118
4119        let model_json = platform.get_ai_model();
4120        assert!(model_json.contains("metadata"));
4121        assert!(model_json.contains("Pong AI"));
4122        assert!(model_json.contains("difficulty_profiles"));
4123    }
4124
4125    #[test]
4126    fn test_web_platform_set_game_mode() {
4127        let config = WebConfig::default();
4128        let mut platform = WebPlatform::new_for_test(config);
4129
4130        platform.set_game_mode("demo");
4131        let _ = platform.frame(0.0, "[]");
4132
4133        platform.set_game_mode("twoplayer");
4134        let _ = platform.frame(16.0, "[]");
4135
4136        platform.set_game_mode("singleplayer");
4137        let _ = platform.frame(33.0, "[]");
4138    }
4139
4140    #[test]
4141    fn test_web_platform_ai_difficulty_accessors() {
4142        let config = WebConfig::default();
4143        let mut platform = WebPlatform::new_for_test(config);
4144
4145        let initial = platform.get_ai_difficulty();
4146        assert!(initial >= 1 && initial <= 10);
4147
4148        platform.set_ai_difficulty(3);
4149        assert_eq!(platform.get_ai_difficulty(), 3);
4150
4151        platform.set_ai_difficulty(9);
4152        assert_eq!(platform.get_ai_difficulty(), 9);
4153    }
4154
4155    #[test]
4156    fn test_pong_game_victory_screen_renders() {
4157        let mut game = PongGame::new(800.0, 600.0, false);
4158        game.set_state(GameState::GameOver);
4159        game.left_score = 11;
4160
4161        let mut frame = RenderFrame::new();
4162        game.render(&mut frame);
4163
4164        // Should render victory text
4165        assert!(frame.len() > 5);
4166    }
4167
4168    #[test]
4169    fn test_pong_game_paused_screen_renders() {
4170        let mut game = PongGame::new(800.0, 600.0, false);
4171        game.set_state(GameState::Paused);
4172
4173        let mut frame = RenderFrame::new();
4174        game.render(&mut frame);
4175
4176        // Should render PAUSED text
4177        assert!(frame.len() > 3);
4178    }
4179
4180    #[test]
4181    fn test_pong_game_menu_screen_renders() {
4182        let mut game = PongGame::new(800.0, 600.0, false);
4183        game.set_state(GameState::Menu);
4184
4185        let mut frame = RenderFrame::new();
4186        game.render(&mut frame);
4187
4188        // Should render menu
4189        assert!(frame.len() > 3);
4190    }
4191
4192    #[test]
4193    fn test_pong_game_demo_mode_ai_controls_both_paddles() {
4194        let mut game = PongGame::new(800.0, 600.0, false);
4195        game.set_state(GameState::Playing);
4196        game.game_mode = GameMode::Demo;
4197
4198        let input = InputState::new();
4199
4200        // Run several frames - AI should move both paddles
4201        for _ in 0..60 {
4202            game.update(&input, 0.016);
4203        }
4204
4205        // Game should still be running
4206        assert!(game.state() == GameState::Playing || game.state() == GameState::Menu);
4207    }
4208
4209    #[test]
4210    fn test_pong_game_two_player_mode() {
4211        let mut game = PongGame::new(800.0, 600.0, false);
4212        game.set_state(GameState::Playing);
4213        game.game_mode = GameMode::TwoPlayer;
4214
4215        let mut input = InputState::new();
4216        let initial_left_y = game.left_paddle_y;
4217        let initial_right_y = game.right_paddle_y;
4218
4219        // Both players control their paddles
4220        input.set_key_pressed(jugar_input::KeyCode::Letter('W'), true);
4221        input.set_key_pressed(jugar_input::KeyCode::Up, true);
4222        game.update(&input, 0.1);
4223
4224        // Both paddles should have moved up
4225        assert!(game.left_paddle_y < initial_left_y);
4226        assert!(game.right_paddle_y < initial_right_y);
4227    }
4228
4229    #[test]
4230    fn test_render_frame_commands_count() {
4231        let config = WebConfig::default();
4232        let mut platform = WebPlatform::new_for_test(config);
4233
4234        let result = platform.frame(0.0, "[]");
4235        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
4236
4237        // Should have commands array
4238        assert!(parsed["commands"].is_array());
4239        assert!(parsed["commands"].as_array().unwrap().len() > 5);
4240    }
4241
4242    #[test]
4243    fn test_speed_multiplier_affects_physics() {
4244        let config = WebConfig::default();
4245        let mut platform = WebPlatform::new_for_test(config);
4246
4247        // Start game
4248        let space = r#"[{"event_type":"KeyDown","timestamp":0,"data":{"key":"Space"}}]"#;
4249        let _ = platform.frame(0.0, space);
4250        let _ = platform.frame(16.0, "[]");
4251
4252        // Set 10x speed
4253        let key_3 = r#"[{"event_type":"KeyDown","timestamp":100,"data":{"key":"Digit3"}}]"#;
4254        let _ = platform.frame(100.0, key_3);
4255
4256        // Run frame at high speed
4257        let result = platform.frame(116.0, "[]");
4258        assert!(!result.is_empty());
4259    }
4260
4261    #[test]
4262    fn test_js_action_download_ai_model() {
4263        let action = JsAction::DownloadAiModel;
4264        let json = serde_json::to_string(&action).unwrap();
4265        assert!(json.contains("DownloadAiModel"));
4266    }
4267
4268    #[test]
4269    fn test_js_action_open_url() {
4270        let action = JsAction::OpenUrl {
4271            url: "https://example.com".to_string(),
4272        };
4273        let json = serde_json::to_string(&action).unwrap();
4274        assert!(json.contains("OpenUrl"));
4275        assert!(json.contains("example.com"));
4276    }
4277
4278    #[test]
4279    fn test_js_action_fullscreen() {
4280        let enter = JsAction::EnterFullscreen;
4281        let exit = JsAction::ExitFullscreen;
4282
4283        let enter_json = serde_json::to_string(&enter).unwrap();
4284        let exit_json = serde_json::to_string(&exit).unwrap();
4285
4286        assert!(enter_json.contains("EnterFullscreen"));
4287        assert!(exit_json.contains("ExitFullscreen"));
4288    }
4289
4290    #[test]
4291    fn test_frame_output_with_actions() {
4292        let output = FrameOutput {
4293            commands: vec![],
4294            audio_events: vec![],
4295            actions: vec![JsAction::DownloadAiModel],
4296            debug_info: None,
4297        };
4298
4299        let json = serde_json::to_string(&output).unwrap();
4300        assert!(json.contains("actions"));
4301        assert!(json.contains("DownloadAiModel"));
4302    }
4303
4304    #[test]
4305    fn test_web_platform_full_gameplay_simulation() {
4306        let config = WebConfig::default();
4307        let mut platform = WebPlatform::new_for_test(config);
4308
4309        // Menu -> Playing
4310        let space = r#"[{"event_type":"KeyDown","timestamp":0,"data":{"key":"Space"}}]"#;
4311        let _ = platform.frame(0.0, space);
4312
4313        // Play for many frames
4314        for i in 1..100 {
4315            let ts = i as f64 * 16.667;
4316            let _ = platform.frame(ts, "[]");
4317        }
4318
4319        // Get stats
4320        let stats = platform.get_stats();
4321        assert!(stats.contains("frame_count"));
4322    }
4323
4324    #[test]
4325    fn test_web_platform_continuous_input() {
4326        let config = WebConfig::default();
4327        let mut platform = WebPlatform::new_for_test(config);
4328
4329        // Start game
4330        let space = r#"[{"event_type":"KeyDown","timestamp":0,"data":{"key":"Space"}}]"#;
4331        let _ = platform.frame(0.0, space);
4332        let _ = platform.frame(16.0, "[]");
4333
4334        // Hold W key for many frames
4335        for i in 0..30 {
4336            let ts = 100.0 + (i as f64 * 16.667);
4337            let w_down = format!(
4338                r#"[{{"event_type":"KeyDown","timestamp":{},"data":{{"key":"KeyW"}}}}]"#,
4339                ts
4340            );
4341            let _ = platform.frame(ts, &w_down);
4342        }
4343    }
4344
4345    #[test]
4346    fn test_pong_game_ball_hits_track_rally() {
4347        let mut game = PongGame::new(800.0, 600.0, false);
4348        game.set_state(GameState::Playing);
4349
4350        assert_eq!(game.rally_count(), 0);
4351
4352        // Hit the ball - should increment rally
4353        game.ball_x = 46.0;
4354        game.ball_y = game.left_paddle_y;
4355        game.ball_vx = -100.0;
4356        let input = InputState::new();
4357        game.update(&input, 0.016);
4358
4359        // Rally should have incremented after paddle hit
4360        assert!(game.rally_count() > 0 || game.ball_vx > 0.0);
4361    }
4362
4363    #[test]
4364    fn test_all_speed_buttons_calculated() {
4365        let hud = HudButtons::calculate(800.0, 600.0);
4366        // All 6 speed buttons should be positioned
4367        assert!(hud.speed_1x.width > 0.0);
4368        assert!(hud.speed_5x.width > 0.0);
4369        assert!(hud.speed_10x.width > 0.0);
4370        assert!(hud.speed_50x.width > 0.0);
4371        assert!(hud.speed_100x.width > 0.0);
4372        assert!(hud.speed_1000x.width > 0.0);
4373    }
4374
4375    #[test]
4376    fn test_ai_buttons_calculated() {
4377        let hud = HudButtons::calculate(800.0, 600.0);
4378        // AI difficulty buttons should be positioned
4379        assert!(hud.ai_decrease.width > 0.0);
4380        assert!(hud.ai_increase.width > 0.0);
4381    }
4382
4383    #[test]
4384    fn test_footer_buttons_calculated() {
4385        let hud = HudButtons::calculate(800.0, 600.0);
4386        // Download and model info buttons
4387        assert!(hud.download.width > 0.0);
4388        assert!(hud.model_info.width > 0.0);
4389    }
4390
4391    #[test]
4392    fn test_ai_shap_widget_renders_in_single_player_mode() {
4393        // Use WebConfig::default which has ai_enabled: true
4394        let config = WebConfig::default();
4395        let mut platform = WebPlatform::new_for_test(config);
4396
4397        // Run a frame - game starts in SinglePlayer mode with AI enabled
4398        let result = platform.frame(0.0, "[]");
4399
4400        // Should render the .apr ML Model widget title
4401        assert!(
4402            result.contains(".apr ML Model"),
4403            "Expected frame output to contain '.apr ML Model' widget text. Output: {}",
4404            &result[..result.len().min(2000)]
4405        );
4406    }
4407
4408    #[test]
4409    fn test_ai_shap_widget_not_rendered_in_two_player_mode() {
4410        let config = WebConfig::default();
4411        let mut platform = WebPlatform::new_for_test(config);
4412
4413        // Switch to 2P mode using the public method
4414        platform.set_game_mode("2p");
4415
4416        // Run a frame
4417        let result = platform.frame(0.0, "[]");
4418
4419        // Should NOT contain the .apr ML Model widget in 2P mode
4420        assert!(
4421            !result.contains(".apr ML Model"),
4422            "Expected frame output to NOT contain '.apr ML Model' widget text in 2P mode"
4423        );
4424    }
4425
4426    #[test]
4427    fn test_footer_buttons_positions_match_render() {
4428        // Test that HudButtons::calculate() produces positions that match render_hud()
4429        let hud = HudButtons::calculate(800.0, 600.0);
4430
4431        // Download button at x=10
4432        assert_eq!(hud.download.x, 10.0, "Download button should be at x=10");
4433        assert!(
4434            hud.download.y > 500.0,
4435            "Download button should be near bottom"
4436        );
4437
4438        // Info button after download
4439        assert!(
4440            hud.model_info.x > hud.download.x + hud.download.width,
4441            "Info button should be after download button"
4442        );
4443
4444        // Sound button after info (with [I] hint space)
4445        let expected_sound_x = hud.model_info.x + hud.model_info.width + 25.0;
4446        assert_eq!(
4447            hud.sound_toggle.x, expected_sound_x,
4448            "Sound button should be 25px after info button (accounting for [I] hint)"
4449        );
4450    }
4451
4452    #[test]
4453    fn test_info_button_click_toggles_panel() {
4454        let config = WebConfig::default();
4455        let mut platform = WebPlatform::new_for_test(config);
4456
4457        // First frame to initialize
4458        let _ = platform.frame(0.0, "[]");
4459
4460        // Get info button position
4461        let hud = HudButtons::calculate(800.0, 600.0);
4462        let click_x = hud.model_info.x + hud.model_info.width / 2.0;
4463        let click_y = hud.model_info.y + hud.model_info.height / 2.0;
4464
4465        // Click on info button
4466        let click = format!(
4467            r#"[{{"event_type":"MouseDown","timestamp":100,"data":{{"button":0,"x":{},"y":{}}}}}]"#,
4468            click_x, click_y
4469        );
4470        let result = platform.frame(100.0, &click);
4471
4472        // Info panel should now be visible (contains more detailed info)
4473        assert!(
4474            result.contains("Model Info") || result.contains("difficulty_profiles"),
4475            "Info panel should be visible after clicking Info button"
4476        );
4477    }
4478
4479    #[test]
4480    fn test_sound_button_click_toggles_sound() {
4481        let config = WebConfig::default();
4482        let mut platform = WebPlatform::new_for_test(config);
4483
4484        // First frame - sound is enabled by default
4485        let result1 = platform.frame(0.0, "[]");
4486        assert!(
4487            result1.contains("Sound"),
4488            "Should show 'Sound' when enabled"
4489        );
4490
4491        // Get sound button position
4492        let hud = HudButtons::calculate(800.0, 600.0);
4493        let click_x = hud.sound_toggle.x + hud.sound_toggle.width / 2.0;
4494        let click_y = hud.sound_toggle.y + hud.sound_toggle.height / 2.0;
4495
4496        // Click on sound button to mute
4497        let click = format!(
4498            r#"[{{"event_type":"MouseDown","timestamp":100,"data":{{"button":0,"x":{},"y":{}}}}}]"#,
4499            click_x, click_y
4500        );
4501        let result2 = platform.frame(100.0, &click);
4502
4503        // Should now show "Muted"
4504        assert!(
4505            result2.contains("Muted"),
4506            "Should show 'Muted' after clicking sound button"
4507        );
4508    }
4509
4510    #[test]
4511    fn test_ai_buttons_dont_overlap_text() {
4512        let hud = HudButtons::calculate(800.0, 600.0);
4513
4514        // AI text starts at bar_x + bar_width + 10 = 40 + 100 + 10 = 150
4515        // "9/9 Nightmare" is ~12 chars at 8px each = 96px
4516        // So text ends around 150 + 96 = 246
4517        // AI buttons should start after this
4518
4519        let text_end_estimate = 40.0 + 100.0 + 10.0 + 96.0; // ~246
4520        assert!(
4521            hud.ai_decrease.x >= text_end_estimate - 10.0, // Allow small overlap
4522            "AI decrease button (x={}) should not overlap AI text (ends ~{})",
4523            hud.ai_decrease.x,
4524            text_end_estimate
4525        );
4526    }
4527
4528    // ==================== Coverage Gap Tests ====================
4529
4530    #[test]
4531    fn test_button_rect_boundary_conditions() {
4532        let btn = ButtonRect::new(10.0, 20.0, 100.0, 50.0);
4533
4534        // Top-left corner (inside)
4535        assert!(btn.contains(10.0, 20.0), "Top-left corner should be inside");
4536
4537        // Bottom-right corner (inside)
4538        assert!(
4539            btn.contains(110.0, 70.0),
4540            "Bottom-right corner should be inside"
4541        );
4542
4543        // Inside the button
4544        assert!(btn.contains(50.0, 45.0), "Center should be inside");
4545
4546        // Just outside left edge
4547        assert!(!btn.contains(9.9, 45.0), "Left of button should be outside");
4548
4549        // Just outside right edge
4550        assert!(
4551            !btn.contains(110.1, 45.0),
4552            "Right of button should be outside"
4553        );
4554
4555        // Just outside top edge
4556        assert!(!btn.contains(50.0, 19.9), "Above button should be outside");
4557
4558        // Just outside bottom edge
4559        assert!(!btn.contains(50.0, 70.1), "Below button should be outside");
4560    }
4561
4562    #[test]
4563    fn test_button_width_calculation() {
4564        // "Test" = 4 chars * 8.0 + 10.0 * 2 = 32 + 20 = 52
4565        let width = HudButtons::button_width("Test", 8.0, 10.0);
4566        assert!((width - 52.0).abs() < 0.01);
4567
4568        // Empty string
4569        let empty_width = HudButtons::button_width("", 8.0, 10.0);
4570        assert!((empty_width - 20.0).abs() < 0.01); // Just padding
4571
4572        // Longer text
4573        let long_width = HudButtons::button_width("HelloWorld", 8.0, 5.0);
4574        // 10 chars * 8.0 + 5.0 * 2 = 80 + 10 = 90
4575        assert!((long_width - 90.0).abs() < 0.01);
4576    }
4577
4578    #[test]
4579    fn test_hud_buttons_ultrawide_layout() {
4580        // Ultra-wide monitor (32:9 aspect ratio)
4581        let hud = HudButtons::calculate(3840.0, 1080.0);
4582
4583        // Verify buttons exist and have positive dimensions
4584        assert!(hud.mode_demo.width > 0.0);
4585        assert!(hud.mode_1p.width > 0.0);
4586        assert!(hud.mode_2p.width > 0.0);
4587
4588        // Speed buttons should be present
4589        assert!(hud.speed_1x.width > 0.0);
4590        assert!(hud.speed_1000x.width > 0.0);
4591
4592        // Buttons should be within canvas bounds
4593        assert!(hud.mode_demo.x + hud.mode_demo.width < 3840.0);
4594        assert!(hud.download.x + hud.download.width < 3840.0);
4595    }
4596
4597    #[test]
4598    fn test_hud_buttons_mobile_layout() {
4599        // Mobile (narrow screen)
4600        let hud = HudButtons::calculate(320.0, 640.0);
4601
4602        // Verify buttons fit on narrow screen
4603        assert!(hud.mode_demo.x >= 0.0);
4604        assert!(hud.mode_demo.x + hud.mode_demo.width <= 320.0);
4605
4606        // All buttons should be within bounds
4607        let all_buttons = [&hud.mode_demo, &hud.mode_1p, &hud.mode_2p, &hud.speed_1x];
4608
4609        for btn in all_buttons {
4610            assert!(
4611                btn.x + btn.width <= 320.0,
4612                "Button at x={} width={} exceeds screen width",
4613                btn.x,
4614                btn.width
4615            );
4616        }
4617    }
4618
4619    #[test]
4620    fn test_hud_buttons_square_layout() {
4621        // Square aspect ratio
4622        let hud = HudButtons::calculate(600.0, 600.0);
4623
4624        // Buttons should be present
4625        assert!(hud.mode_demo.width > 0.0);
4626        assert!(hud.ai_decrease.width > 0.0);
4627        assert!(hud.ai_increase.width > 0.0);
4628    }
4629
4630    // ==================== High-Priority Coverage Tests ====================
4631
4632    #[test]
4633    fn test_web_config_from_json_full() {
4634        let json =
4635            r#"{"width":1920,"height":1080,"target_fps":144,"debug":true,"ai_enabled":false}"#;
4636        let config = WebConfig::from_json(json);
4637        assert!(config.is_ok());
4638        let config = config.unwrap();
4639        assert_eq!(config.width, 1920);
4640        assert_eq!(config.height, 1080);
4641        assert_eq!(config.target_fps, 144);
4642        assert!(config.debug);
4643        assert!(!config.ai_enabled);
4644    }
4645
4646    #[test]
4647    fn test_web_config_from_json_partial() {
4648        // JSON with only some fields - others should use defaults
4649        let json = r#"{"width":640}"#;
4650        let config = WebConfig::from_json(json);
4651        assert!(config.is_ok());
4652        let config = config.unwrap();
4653        assert_eq!(config.width, 640);
4654        assert_eq!(config.height, 600); // default
4655        assert_eq!(config.target_fps, 60); // default
4656        assert!(!config.debug); // default
4657        assert!(config.ai_enabled); // default
4658    }
4659
4660    #[test]
4661    fn test_web_config_from_json_malformed() {
4662        let result = WebConfig::from_json("not json at all {{{");
4663        assert!(result.is_err());
4664    }
4665
4666    #[test]
4667    fn test_web_platform_error_from_conversion() {
4668        let input_err = InputTranslationError::InvalidJson("test error".to_string());
4669        let platform_err: WebPlatformError = input_err.into();
4670        assert!(matches!(platform_err, WebPlatformError::InputError(_)));
4671        let err_str = platform_err.to_string();
4672        assert!(err_str.contains("Input error"));
4673    }
4674
4675    #[test]
4676    fn test_web_platform_error_all_variants() {
4677        let config_err = WebPlatformError::InvalidConfig("bad config".to_string());
4678        assert_eq!(config_err.to_string(), "Invalid config: bad config");
4679
4680        let input_err = WebPlatformError::InputError("bad input".to_string());
4681        assert_eq!(input_err.to_string(), "Input error: bad input");
4682
4683        let render_err = WebPlatformError::RenderError("bad render".to_string());
4684        assert_eq!(render_err.to_string(), "Render error: bad render");
4685    }
4686
4687    #[test]
4688    fn test_button_rect_constructor() {
4689        let btn = ButtonRect::new(10.0, 20.0, 100.0, 50.0);
4690        assert!((btn.x - 10.0).abs() < 0.001);
4691        assert!((btn.y - 20.0).abs() < 0.001);
4692        assert!((btn.width - 100.0).abs() < 0.001);
4693        assert!((btn.height - 50.0).abs() < 0.001);
4694    }
4695
4696    #[test]
4697    fn test_hud_buttons_model_info_button_layout() {
4698        let hud = HudButtons::calculate(800.0, 600.0);
4699        // Model info button should be present with positive dimensions
4700        assert!(hud.model_info.width > 0.0);
4701        assert!(hud.model_info.height > 0.0);
4702        // Should be within screen bounds
4703        assert!(hud.model_info.x + hud.model_info.width <= 800.0);
4704    }
4705
4706    #[test]
4707    fn test_hud_buttons_sound_toggle_layout() {
4708        let hud = HudButtons::calculate(800.0, 600.0);
4709        // Sound toggle button should be present
4710        assert!(hud.sound_toggle.width > 0.0);
4711        assert!(hud.sound_toggle.height > 0.0);
4712        assert!(hud.sound_toggle.x >= 0.0);
4713    }
4714
4715    #[test]
4716    fn test_hud_buttons_download_button_layout() {
4717        let hud = HudButtons::calculate(800.0, 600.0);
4718        // Download button should be present
4719        assert!(hud.download.width > 0.0);
4720        assert!(hud.download.height > 0.0);
4721    }
4722}