brkrs 0.0.1

Breakout/Arkanoid-style game built in Rust using the Bevy engine, with physics powered by bevy_rapier3d
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
//!
//! You can toggle wireframes with the space bar except on wasm. Wasm does not support
//! `POLYGON_MODE_LINE` on the gpu.
//!
//! Keyboard commands:
//! - R: Restart current level
//! - L: Switch to next level
//! - K: Destroy all bricks (for testing level transitions)
//! - ESC: Pause game (click to resume)

pub mod level_format;
pub mod level_loader;
pub mod pause;
pub mod systems;
pub mod ui;

#[cfg(feature = "texture_manifest")]
use crate::systems::TextureManifestPlugin;
use crate::systems::{AudioPlugin, InputLocked, LevelSwitchPlugin, RespawnPlugin, RespawnSystems};

#[cfg(not(target_arch = "wasm32"))]
use bevy::pbr::wireframe::{WireframeConfig, WireframePlugin};
#[cfg(not(target_arch = "wasm32"))]
use bevy::window::MonitorSelection;
use bevy::{
    asset::RenderAssetUsages,
    color::palettes::{basic::SILVER, css::RED},
    ecs::message::{MessageReader, MessageWriter},
    input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll},
    prelude::*,
    render::render_resource::{Extent3d, TextureDimension, TextureFormat},
    window::{CursorGrabMode, CursorOptions, PrimaryWindow, Window, WindowMode, WindowPlugin},
};
use bevy_rapier3d::prelude::*;

const BALL_RADIUS: f32 = 0.3;
const PADDLE_RADIUS: f32 = 0.3;
const PADDLE_HEIGHT: f32 = 3.0;
const PLANE_H: f32 = 30.0;
const PLANE_W: f32 = 40.0;

// Bounce/impulse tuning
// How strongly the wall collision pushes the ball (ExternalImpulse on balls)
const BALL_WALL_IMPULSE_FACTOR: f32 = 0.001;
// How strongly the paddle bounces back when hitting a wall
const PADDLE_BOUNCE_WALL_FACTOR: f32 = 0.03;
// How strongly the paddle bounces back when hitting a brick (separate from walls)
const PADDLE_BOUNCE_BRICK_FACTOR: f32 = 0.02;
// Maximum ball velocity (can be made ball-type dependent in the future)
const MAX_BALL_VELOCITY: f32 = 20.0;
// Camera shake parameters
const CAMERA_SHAKE_DURATION: f32 = 0.15;
const CAMERA_SHAKE_IMPULSE_SCALE: f32 = 0.005; // Scale factor for impulse to shake intensity
const CAMERA_SHAKE_MIN_INTENSITY: f32 = 0.05;
const CAMERA_SHAKE_MAX_INTENSITY: f32 = 10.0;
// Paddle growth animation duration
const PADDLE_GROWTH_DURATION: f32 = 2.0;

// Grid debug overlay constants (20x20 grid covering PLANE_H × PLANE_W)
const GRID_WIDTH: usize = 20; // Columns (Z-axis)
const GRID_HEIGHT: usize = 20; // Rows (X-axis)
const CELL_WIDTH: f32 = PLANE_W / GRID_WIDTH as f32; // 2.0 (Z dimension)
const CELL_HEIGHT: f32 = PLANE_H / GRID_HEIGHT as f32; // 1.5 (X dimension)
                                                       // Cell aspect ratio: CELL_HEIGHT / CELL_WIDTH = 30/40 * 20/20 = 3/4 = 0.75
/// A marker component for our shapes so we can query them separately from the ground plane
#[derive(Component)]
pub struct Paddle;
#[derive(Component)]
pub struct Ball;

/// Type ID for ball variants (used by texture manifest type_variants).
/// When changed, the ball-type watcher system will swap materials accordingly.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct BallTypeId(pub u8);

/// Marker component for sidewall/border entities.
/// Used by per-level texture override system to apply custom sidewall materials.
#[derive(Component)]
pub struct Border;

/// Marker component for the ground plane entity.
/// Used by per-level texture override system to apply custom ground materials.
#[derive(Component)]
pub struct GroundPlane;

#[derive(Component)]
pub struct LowerGoal;
#[derive(Component)]
pub struct GridOverlay;
#[derive(Component)]
pub struct Brick;

/// Type ID for brick variants (used by texture manifest type_variants).
/// Applied during brick spawn based on level matrix values.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct BrickTypeId(pub u8);

#[derive(Component)]
struct MarkedForDespawn;
#[derive(Component)]
/// Marker component attached to bricks that should count toward level completion
/// (i.e. destructible bricks). Indestructible bricks MUST NOT have this component.
pub struct CountsTowardsCompletion;
#[derive(Component)]
struct CameraShake {
    timer: Timer,
    intensity: f32,
    original_position: Vec3,
}

#[derive(Component)]
pub struct PaddleGrowing {
    pub timer: Timer,
    pub target_scale: Vec3,
}

#[derive(Component)]
pub struct BallFrozen;

/// Emitted when the paddle collides with a wall boundary.
/// Used by the audio system to play paddle-wall collision sounds.
#[derive(Event)]
pub struct WallHit {
    /// The collision impulse.
    pub impulse: Vec3,
}

/// Emitted when the paddle collides with a brick.
/// Used by the audio system to play paddle-brick collision sounds.
#[derive(Event)]
pub struct BrickHit {
    /// The collision impulse.
    pub impulse: Vec3,
}

/// Emitted when the paddle collides with the ball.
/// Used by the audio system to play paddle hit sounds.
#[derive(Event)]
pub struct BallHit {
    /// The collision impulse.
    pub impulse: Vec3,
    /// The ball entity that was hit.
    pub ball: Entity,
}

/// Stores configurable gravity values (normal gameplay gravity, etc.)
#[derive(Resource)]
struct GravityConfig {
    normal: Vec3,
}

impl Default for GravityConfig {
    fn default() -> Self {
        Self {
            normal: Vec3::new(2.0, 0.0, 0.0),
        }
    }
}

#[derive(Resource, Default)]
pub struct GameProgress {
    finished: bool,
}

pub fn run() {
    let mut app = App::new();

    app.insert_resource(GravityConfig::default());
    app.insert_resource(GameProgress::default());
    // designer palette UI state
    app.init_resource::<ui::palette::PaletteState>();
    app.init_resource::<ui::palette::SelectedBrick>();
    app.insert_resource(level_loader::LevelAdvanceState::default());
    app.add_plugins((
        DefaultPlugins
            .set(ImagePlugin::default_nearest())
            .set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Brkrs".to_string(),
                    #[cfg(not(target_arch = "wasm32"))]
                    mode: WindowMode::BorderlessFullscreen(MonitorSelection::Current),
                    #[cfg(target_arch = "wasm32")]
                    mode: WindowMode::Windowed,
                    ..default()
                }),
                ..default()
            }),
        #[cfg(not(target_arch = "wasm32"))]
        WireframePlugin::default(),
    ));
    app.add_plugins(RapierPhysicsPlugin::<NoUserData>::default());
    app.add_plugins(LevelSwitchPlugin);
    app.add_plugins(crate::level_loader::LevelLoaderPlugin);
    // app.add_plugins(RapierDebugRenderPlugin::default());
    app.add_plugins(RespawnPlugin);
    app.add_plugins(crate::pause::PausePlugin);
    app.add_plugins(AudioPlugin);

    #[cfg(feature = "texture_manifest")]
    {
        app.add_plugins(TextureManifestPlugin);
    }

    app.add_systems(
        Startup,
        (setup, spawn_border, systems::grid_debug::spawn_grid_overlay),
    );
    app.add_systems(
        Update,
        (
            move_paddle
                .after(RespawnSystems::Control)
                .run_if(crate::pause::not_paused),
            limit_ball_velocity,
            update_camera_shake,
            update_paddle_growth,
            stabilize_frozen_balls.before(crate::level_loader::LevelAdvanceSystems),
            restore_gravity_post_growth,
            #[cfg(not(target_arch = "wasm32"))]
            toggle_wireframe,
            #[cfg(not(target_arch = "wasm32"))]
            systems::grid_debug::toggle_grid_visibility,
            grab_mouse,
            read_character_controller_collisions,
            detect_ball_wall_collisions,
            mark_brick_on_ball_collision,
            despawn_marked_entities, // Runs after marking, allowing physics to resolve
            // display_events,
            // designer palette - toggle with P
            ui::palette::toggle_palette,
            ui::palette::ensure_palette_ui,
            ui::palette::handle_palette_selection,
            ui::palette::update_palette_selection_feedback,
            ui::palette::update_ghost_preview,
            ui::palette::place_bricks_on_drag,
            #[cfg(feature = "texture_manifest")]
            systems::multi_hit::watch_brick_type_changes,
        ),
    );
    app.add_observer(on_wall_hit);
    app.add_observer(on_paddle_ball_hit);
    app.add_observer(on_brick_hit);
    app.add_observer(start_camera_shake);
    // Note: Multi-hit brick sound observer is now registered by AudioPlugin
    app.run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut images: ResMut<Assets<Image>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut rapier_config: Query<&mut RapierConfiguration>,
    gravity_cfg: Res<GravityConfig>,
) {
    let rapier_config = rapier_config.single_mut();
    // Set gravity for normal gameplay (respawn will temporarily disable it)
    rapier_config.unwrap().gravity = gravity_cfg.normal;

    let _debug_material = materials.add(StandardMaterial {
        base_color_texture: Some(images.add(uv_debug_texture())),
        ..default()
    });

    // Level entities (paddle, ball, bricks) are spawned by LevelLoaderPlugin after level parsing.

    // light
    commands.spawn((
        PointLight {
            shadows_enabled: true,
            intensity: 10_000_000.,
            range: 100.0,
            shadow_depth_bias: 0.2,
            ..default()
        },
        Transform::from_xyz(-4.0, 20.0, 2.0),
    ));

    // ground plane
    commands.spawn((
        Mesh3d(
            meshes.add(
                Plane3d::default()
                    .mesh()
                    .size(PLANE_H, PLANE_W)
                    .subdivisions(4),
            ),
        ),
        MeshMaterial3d(materials.add(Color::from(SILVER))),
        GroundPlane,
    ));

    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(0.0, 37., 0.0).looking_at(Vec3::new(0., 0., 0.), Vec3::Y),
        MainCamera,
    ));

    #[derive(Component)]
    struct MainCamera;

    #[cfg(not(target_arch = "wasm32"))]
    commands.spawn((
        Text::new("Press space to toggle wire frames"),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(12.0),
            left: Val::Px(12.0),
            ..default()
        },
    ));
}

/// Apply speed-dependent damping to control ball velocity
fn limit_ball_velocity(mut balls: Query<(&Velocity, &mut Damping), With<Ball>>) {
    for (velocity, mut damping) in balls.iter_mut() {
        let speed = velocity.linvel.length();

        // Calculate damping based on speed relative to target velocity
        // Higher speeds get more damping, lower speeds get less
        let speed_ratio = speed / MAX_BALL_VELOCITY;

        if speed_ratio > 1.0 {
            // Above target: increase damping exponentially
            damping.linear_damping = 0.5 + (speed_ratio - 1.0) * 2.0;
        } else if speed_ratio < 0.5 {
            // Below half target: reduce damping to allow acceleration
            damping.linear_damping = 0.01 + speed_ratio * 0.8;
        } else {
            // Near target: moderate damping
            damping.linear_damping = 0.1;
        }

        // Clamp damping to reasonable bounds
        damping.linear_damping = damping.linear_damping.clamp(0.1, 5.0);
    }
}

/// Update camera shake effect
fn update_camera_shake(
    time: Res<Time>,
    mut cameras: Query<(Entity, &mut Transform, Option<&mut CameraShake>)>,
    mut commands: Commands,
) {
    for (entity, mut transform, shake_opt) in cameras.iter_mut() {
        if let Some(mut shake) = shake_opt {
            shake.timer.tick(time.delta());

            if shake.timer.is_finished() {
                // Restore original position and remove shake component
                transform.translation = shake.original_position;
                commands.entity(entity).remove::<CameraShake>();
            } else {
                // Apply random offset based on intensity
                let progress = shake.timer.fraction();
                let intensity = shake.intensity * (1.0 - progress); // Fade out
                let offset = Vec3::new(
                    (time.elapsed_secs() * 50.0).sin() * intensity,
                    0.0,
                    (time.elapsed_secs() * 43.0).cos() * intensity,
                );
                transform.translation = shake.original_position + offset;
            }
        }
    }
}

/// Observer to start camera shake
fn start_camera_shake(
    trigger: On<StartCameraShake>,
    mut cameras: Query<(Entity, &Transform), (With<Camera3d>, Without<CameraShake>)>,
    mut commands: Commands,
) {
    let event = trigger.event();
    // Calculate intensity based on impulse magnitude
    let impulse_magnitude = event.impulse.length();
    let intensity = (impulse_magnitude * CAMERA_SHAKE_IMPULSE_SCALE)
        .clamp(CAMERA_SHAKE_MIN_INTENSITY, CAMERA_SHAKE_MAX_INTENSITY);

    for (entity, transform) in cameras.iter_mut() {
        commands.entity(entity).insert(CameraShake {
            timer: Timer::from_seconds(CAMERA_SHAKE_DURATION, TimerMode::Once),
            intensity,
            original_position: transform.translation,
        });
    }
}

/// Animate paddle growth over PADDLE_GROWTH_DURATION seconds
fn update_paddle_growth(
    time: Res<Time>,
    mut paddles: Query<(Entity, &mut Transform, &mut PaddleGrowing)>,
    mut rapier_config: Query<&mut RapierConfiguration>,
    gravity_cfg: Res<GravityConfig>,
    mut commands: Commands,
) {
    for (entity, mut transform, mut growing) in paddles.iter_mut() {
        growing.timer.tick(time.delta());

        if growing.timer.is_finished() {
            // Growth complete: set final scale, enable gravity, remove component
            transform.scale = growing.target_scale;
            if let Ok(mut config) = rapier_config.single_mut() {
                config.gravity = gravity_cfg.normal;
            }
            commands.entity(entity).remove::<PaddleGrowing>();
            info!(
                "Paddle growth completed, gravity restored to {:?}",
                gravity_cfg.normal
            );
        } else {
            // Interpolate scale from near-zero to target
            let progress = growing.timer.fraction();
            // Use smooth easing function (ease-out cubic)
            let eased_progress = 1.0 - (1.0 - progress).powi(3);
            transform.scale = Vec3::splat(0.01).lerp(growing.target_scale, eased_progress);
        }
    }
}

/// Ensure gravity is restored if growth finished but previous restoration was missed.
/// Acts as a safety net in case the growth completion frame didn't run gravity restoration.
fn restore_gravity_post_growth(
    paddles: Query<&PaddleGrowing>,
    mut rapier_config: Query<&mut RapierConfiguration>,
    gravity_cfg: Res<GravityConfig>,
) {
    // Only restore if no paddle is growing and gravity is currently zero.
    if paddles.is_empty() {
        if let Ok(mut config) = rapier_config.single_mut() {
            if config.gravity == Vec3::ZERO {
                config.gravity = gravity_cfg.normal;
            }
        }
    }
}

/// Keep ball frozen (zero velocity, locked position) while paddle is growing
fn stabilize_frozen_balls(
    mut balls: Query<(Entity, Option<&mut Velocity>, &BallFrozen), With<Ball>>,
    mut commands: Commands,
) {
    for (entity, velocity, _frozen) in balls.iter_mut() {
        if let Some(mut vel) = velocity {
            // Ball has Velocity component, zero it out
            vel.linvel = Vec3::ZERO;
            vel.angvel = Vec3::ZERO;
        } else {
            // Ball doesn't have Velocity component, add it with zero velocity
            commands.entity(entity).insert(Velocity::zero());
        }
    }
}

fn move_paddle(
    mut query: Query<&mut Transform, (With<Paddle>, Without<InputLocked>)>,
    time: Res<Time>,
    mut controllers: Query<&mut KinematicCharacterController, (With<Paddle>, Without<InputLocked>)>,
    accumulated_mouse_motion: Res<AccumulatedMouseMotion>,
    accumulated_mouse_scroll: Res<AccumulatedMouseScroll>,
    window: Single<&Window, With<PrimaryWindow>>,
    growing: Query<&PaddleGrowing>,
) {
    if !window.focused {
        return;
    }
    // If paddle is currently growing, ignore input and movement entirely.
    if !growing.is_empty() {
        return;
    }
    let _sensitivity = 100.0 / window.height().min(window.width());
    if query.is_empty() {
        return;
    }

    for mut controller in controllers.iter_mut() {
        controller.translation = Some(
            Vec3::new(
                accumulated_mouse_motion.delta.y,
                0.0,
                -accumulated_mouse_motion.delta.x,
            ) * 0.000_4
                / time.delta_secs(),
        );
    }
    for mut transform in &mut query {
        // Allow rotation only when not growing
        transform.rotate_y(accumulated_mouse_scroll.delta.y * time.delta_secs() * 3.0);
        transform.translation.y = 2.0; // force the paddle to stay at the same height

        // Constrain paddle to play area bounds (with some padding for paddle size)
        let padding = PADDLE_HEIGHT / 2.0;
        let x_min = -PLANE_H / 2.0 + padding;
        let x_max = PLANE_H / 2.0 - padding;
        let z_min = -PLANE_W / 2.0 + padding;
        let z_max = PLANE_W / 2.0 - padding;

        transform.translation.x = transform.translation.x.clamp(x_min, x_max);
        transform.translation.z = transform.translation.z.clamp(z_min, z_max);
    }
}

fn spawn_border(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    _images: ResMut<Assets<Image>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    let border_material = materials.add(StandardMaterial {
        base_color: Color::from(RED),
        ..default()
    });

    // upper border
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(1.0, 5.0, PLANE_W).mesh())),
        MeshMaterial3d(border_material.clone()),
        Transform::from_xyz(-15.5, 0.0, 0.0),
        Collider::cuboid(1.0, 2.5, PLANE_W / 2.0),
        Border,
    ));
    //
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(PLANE_H, 5.0, 1.0).mesh())),
        MeshMaterial3d(border_material.clone()),
        Transform::from_xyz(-0.0, 0.0, -20.5),
        Collider::cuboid(PLANE_H / 2.0, 2.5, 0.5),
        Border,
    ));
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(PLANE_H, 5.0, 1.0).mesh())),
        MeshMaterial3d(border_material.clone()),
        Transform::from_xyz(-0.0, 0.0, 20.5),
        Collider::cuboid(PLANE_H / 2.0, 2.5, 0.5),
        Border,
    ));
    //  lower border
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(0.0, 5.0, PLANE_W).mesh())),
        MeshMaterial3d(materials.add(StandardMaterial {
            base_color: Color::srgba(0.0, 0.0, 0.0, 1.0),
            //alpha_mode: AlphaMode::Mask(0.0),
            unlit: true,
            ..default()
        })),
        Transform::from_xyz(15.5, 0.0, 0.0),
        Collider::cuboid(0.0, 2.5, PLANE_W / 2.0),
        //Sensor::default(),
        Border,
    ));
}

/// Creates a colorful test pattern
fn uv_debug_texture() -> Image {
    const TEXTURE_SIZE: usize = 8;

    let mut palette: [u8; 32] = [
        255, 102, 159, 255, 255, 159, 102, 255, 236, 255, 102, 255, 121, 255, 102, 255, 102, 255,
        198, 255, 102, 198, 255, 255, 121, 102, 255, 255, 236, 102, 255, 255,
    ];

    let mut texture_data = [0; TEXTURE_SIZE * TEXTURE_SIZE * 4];
    for y in 0..TEXTURE_SIZE {
        let offset = TEXTURE_SIZE * y * 4;
        texture_data[offset..(offset + TEXTURE_SIZE * 4)].copy_from_slice(&palette);
        palette.rotate_right(4);
    }

    Image::new_fill(
        Extent3d {
            width: TEXTURE_SIZE as u32,
            height: TEXTURE_SIZE as u32,
            depth_or_array_layers: 1,
        },
        TextureDimension::D2,
        &texture_data,
        TextureFormat::Rgba8UnormSrgb,
        RenderAssetUsages::RENDER_WORLD,
    )
}

/// Mark bricks for despawn when hit by the ball, or transition multi-hit bricks.
///
/// Multi-hit bricks (indices 10-13) transition to the next lower index instead of
/// being despawned immediately. When a multi-hit brick at index 10 is hit, it
/// transitions to index 20 (simple stone), which can then be destroyed on the next hit.
///
/// This allows the physics collision response to complete before removal.
fn mark_brick_on_ball_collision(
    mut collision_events: MessageReader<CollisionEvent>,
    balls: Query<Entity, With<Ball>>,
    // Query bricks with their type ID for multi-hit handling
    mut bricks: Query<
        (Entity, &mut BrickTypeId),
        (
            With<Brick>,
            With<CountsTowardsCompletion>,
            Without<MarkedForDespawn>,
        ),
    >,
    mut commands: Commands,
) {
    use crate::level_format::{is_multi_hit_brick, MULTI_HIT_BRICK_1, SIMPLE_BRICK};

    for event in collision_events.read() {
        // collision event processed
        if let CollisionEvent::Started(e1, e2, _) = event {
            let e1_is_ball = balls.get(*e1).is_ok();
            let e2_is_ball = balls.get(*e2).is_ok();

            // Determine which entity is the brick (if any)
            let brick_entity = if e1_is_ball {
                bricks.get_mut(*e2).ok()
            } else if e2_is_ball {
                bricks.get_mut(*e1).ok()
            } else {
                None
            };

            if let Some((entity, mut brick_type)) = brick_entity {
                let current_type = brick_type.0;

                if is_multi_hit_brick(current_type) {
                    // Multi-hit brick: transition to next state
                    let new_type = if current_type == MULTI_HIT_BRICK_1 {
                        // Index 10 transitions to index 20 (simple stone)
                        SIMPLE_BRICK
                    } else {
                        // Index 13, 12, 11 transition to index - 1
                        current_type - 1
                    };

                    // Emit event for audio/scoring integration
                    commands.trigger(systems::MultiHitBrickHit {
                        entity,
                        previous_type: current_type,
                        new_type,
                    });

                    // Update the brick type (this triggers watch_brick_type_changes for visual update)
                    brick_type.0 = new_type;

                    debug!(
                        "Multi-hit brick {:?} transitioned: {} -> {}",
                        entity, current_type, new_type
                    );
                } else {
                    // Regular brick: mark for despawn
                    commands.entity(entity).insert(MarkedForDespawn);
                }
            }
        }
    }
}

/// Detect ball-wall collisions and emit BallWallHit events for audio.
fn detect_ball_wall_collisions(
    mut collision_events: MessageReader<CollisionEvent>,
    balls: Query<(Entity, &Velocity), With<Ball>>,
    borders: Query<Entity, With<Border>>,
    mut commands: Commands,
) {
    for event in collision_events.read() {
        if let CollisionEvent::Started(e1, e2, _) = event {
            // Check if one entity is a ball and the other is a border
            let ball_data = balls.get(*e1).ok().or_else(|| balls.get(*e2).ok());
            let is_border = borders.get(*e1).is_ok() || borders.get(*e2).is_ok();

            if let (Some((ball_entity, velocity)), true) = (ball_data, is_border) {
                // Emit BallWallHit event for audio system
                commands.trigger(systems::BallWallHit {
                    entity: ball_entity,
                    impulse: velocity.linvel,
                });
            }
        }
    }
}

/// Despawn entities marked for removal (runs after physics step).
/// Emits BrickDestroyed events for audio system integration.
fn despawn_marked_entities(
    marked: Query<(Entity, Option<&BrickTypeId>), With<MarkedForDespawn>>,
    mut commands: Commands,
) {
    for (entity, brick_type) in marked.iter() {
        // Emit BrickDestroyed event for audio system
        if let Some(brick_type) = brick_type {
            commands.trigger(systems::BrickDestroyed {
                entity,
                brick_type: brick_type.0,
            });
        }
        commands.entity(entity).despawn();
    }
}

/// Public helper to register the brick collision + despawn systems on an arbitrary App.
/// Tests can call this to mimic the runtime configuration used by the main app.
pub fn register_brick_collision_systems(app: &mut App) {
    app.add_systems(
        Update,
        (mark_brick_on_ball_collision, despawn_marked_entities),
    );
}

#[cfg(not(target_arch = "wasm32"))]
fn toggle_wireframe(
    mut wireframe_config: ResMut<WireframeConfig>,
    keyboard: Res<ButtonInput<KeyCode>>,
) {
    if keyboard.just_pressed(KeyCode::Space) {
        wireframe_config.global = !wireframe_config.global;
    }
}

fn grab_mouse(
    window: Single<&Window, With<PrimaryWindow>>,
    mut cursor_options: Single<&mut CursorOptions, With<PrimaryWindow>>,
    mouse: Res<ButtonInput<MouseButton>>,
    key: Res<ButtonInput<KeyCode>>,
    mut app_exit: MessageWriter<AppExit>,
) {
    if !window.focused {
        return;
    }
    if mouse.just_pressed(MouseButton::Left) {
        cursor_options.visible = false;
        cursor_options.grab_mode = CursorGrabMode::Locked;
    }

    if key.just_pressed(KeyCode::Escape) {
        cursor_options.visible = true;
        cursor_options.grab_mode = CursorGrabMode::None;
    }

    if key.just_pressed(KeyCode::KeyQ) {
        app_exit.write(AppExit::Success);
    }
}

/* Read the character controller collisions stored in the character controller’s output. */
fn read_character_controller_collisions(
    paddle_outputs: Query<&KinematicCharacterControllerOutput, With<Paddle>>,
    walls: Query<Entity, With<Border>>,
    bricks: Query<Entity, With<Brick>>,
    balls: Query<Entity, With<Ball>>,
    time: Res<Time>,
    accumulated_mouse_motion: Res<AccumulatedMouseMotion>,
    mut commands: Commands,
) {
    let output = match paddle_outputs.single() {
        Ok(controller) => controller,
        Err(_) => return,
    };
    for collision in output.collisions.iter() {
        // paddle collides with the walls
        for wall in walls.iter() {
            if collision.entity == wall {
                commands.trigger(WallHit {
                    impulse: (collision.translation_applied + collision.translation_remaining)
                        / time.delta_secs(),
                });
            }
        }
    }
    for collision in output.collisions.iter() {
        // paddle collides with the bricks: emit BrickHit (separate from walls)
        for brick in bricks.iter() {
            if collision.entity == brick {
                commands.trigger(BrickHit {
                    impulse: (collision.translation_applied + collision.translation_remaining)
                        / time.delta_secs(),
                });
            }
        }
    }
    for collision in output.collisions.iter() {
        // paddle collides with the balls
        for ball in balls.iter() {
            if collision.entity == ball {
                // println!("hit ball {:?}", ball);
                println!("collision {:?}", collision);
                commands.trigger(BallHit {
                    impulse: Vec3::new(
                        accumulated_mouse_motion.delta.y,
                        0.0,
                        -accumulated_mouse_motion.delta.x,
                    ) / time.delta_secs(),
                    ball,
                });
            }
        }
    }
}

fn on_wall_hit(
    trigger: On<WallHit>,
    mut balls: Query<&mut ExternalImpulse, With<Ball>>,
    mut controllers: Query<&mut KinematicCharacterController, With<Paddle>>,
    mut commands: Commands,
) {
    let event = trigger.event();

    // give the balls an impulse
    for mut impulse in balls.iter_mut() {
        impulse.impulse = event.impulse * BALL_WALL_IMPULSE_FACTOR;
    }

    // let the paddle bounce back on wall collisions as well
    for mut controller in controllers.iter_mut() {
        controller.translation = Some(-event.impulse * PADDLE_BOUNCE_WALL_FACTOR);
    }

    // Trigger camera shake with impulse-based intensity
    commands.trigger(StartCameraShake {
        impulse: event.impulse,
    });
}

#[derive(Event)]
struct StartCameraShake {
    impulse: Vec3,
}

fn on_brick_hit(
    trigger: On<BrickHit>,
    mut controllers: Query<&mut KinematicCharacterController, With<Paddle>>,
    mut commands: Commands,
) {
    let event = trigger.event();

    // let the paddle bounce back on brick collisions only
    for mut controller in controllers.iter_mut() {
        controller.translation = Some(-event.impulse * PADDLE_BOUNCE_BRICK_FACTOR);
    }

    // Trigger camera shake with impulse-based intensity
    commands.trigger(StartCameraShake {
        impulse: event.impulse,
    });
}

fn on_paddle_ball_hit(
    trigger: On<BallHit>,
    mut balls: Query<(Entity, &mut ExternalImpulse), With<Ball>>,
) {
    let event = trigger.event();
    println!("Received ball hit event: {:?}", event.impulse);

    // give the balls an impulse with "english" - paddle rotation affects ball trajectory
    // Tuned multiplier for noticeable but controlled steering effect
    for (ball, mut impulse) in balls.iter_mut() {
        if ball == event.ball {
            impulse.impulse = event.impulse * 0.001; // Increased from 0.000_2 for more noticeable effect
        }
    }
}