use bevy::prelude::*;
use bevy::reflect::TypePath;
use bevy::render::render_resource::AsBindGroup;
use bevy::shader::ShaderRef;
use bevy::sprite_render::{AlphaMode2d, Material2d, Material2dPlugin};
use bevy_ecs_tiled::prelude::*;
use bevy_tween::interpolate::Interpolator;
use bevy_tween::prelude::*;
use std::collections::HashMap;
use std::time::Duration;
pub const FADE_EIGHTH_NOTES: u32 = 32;
pub const FADE_TARGET: f32 = 0.5;
pub const FADE_INITIAL: f32 = 1.0;
#[allow(dead_code)]
pub const BLACK_WHITE_THRESHOLD: f32 = 0.125;
#[derive(Component)]
pub struct RevealedTileSprite {
pub manhattan_distance: u32,
pub tile_pos: (u32, u32),
pub direction: RippleDirection,
}
#[derive(Component)]
pub struct TileFadeState {
pub fade: f32,
pub random_offset: u32,
pub eighth_notes_elapsed: u32,
}
#[derive(Component)]
pub struct AnimatingTile {
pub animation_timer: Timer,
}
#[derive(Component)]
pub struct AnimationComplete;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RippleDirection {
Up,
Down,
Left,
Right,
}
#[derive(Component)]
pub struct RevealTilesRoot;
impl RippleDirection {
pub fn all() -> [RippleDirection; 4] {
[
RippleDirection::Up,
RippleDirection::Down,
RippleDirection::Left,
RippleDirection::Right,
]
}
}
#[derive(Resource)]
pub struct TileRevealState {
pub origin: Vec2,
pub current_step: u32,
pub max_distance: u32,
pub all_triggered: bool,
pub initialized: bool,
pub tiles_hidden: bool,
pub used_directions: Vec<RippleDirection>,
pub pending_tiles_by_direction: HashMap<RippleDirection, Vec<Entity>>,
}
impl Default for TileRevealState {
fn default() -> Self {
Self {
origin: Vec2::ZERO,
current_step: 0,
max_distance: 0,
all_triggered: false,
initialized: false,
tiles_hidden: false,
used_directions: Vec::new(),
pending_tiles_by_direction: HashMap::new(),
}
}
}
#[derive(Resource, Default)]
pub struct TilemapTextureCache {
pub textures: Vec<Handle<Image>>,
pub tile_size: Vec2,
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct BlackWhiteMaterial {
#[uniform(0)]
pub threshold: f32,
#[texture(1)]
#[sampler(2)]
pub texture: Handle<Image>,
#[uniform(3)]
pub uv_rect: Vec4,
}
impl Material2d for BlackWhiteMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/black_white_tile.wgsl".into()
}
fn alpha_mode(&self) -> AlphaMode2d {
AlphaMode2d::Blend
}
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone, Default)]
pub struct BlackWhiteTilemapMaterial {}
impl MaterialTilemap for BlackWhiteTilemapMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/black_white_tilemap.wgsl".into()
}
}
#[derive(Component)]
pub struct HasBlackWhiteMaterial;
pub struct TileRevealPlugin;
impl Plugin for TileRevealPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(Material2dPlugin::<BlackWhiteMaterial>::default())
.add_plugins(MaterialTilemapPlugin::<BlackWhiteTilemapMaterial>::default())
.init_resource::<TileRevealState>()
.init_resource::<TilemapTextureCache>()
.add_tween_systems(bevy_tween::tween::component_tween_system::<ScaleInterpolator>())
.add_systems(
Update,
(
apply_black_white_tilemap_material_system,
cache_tilemap_textures_system,
initialize_tile_colors_system,
hide_all_original_tiles_system,
create_tile_sprites_system,
collect_pending_tiles_system,
update_reveal_animation_system,
check_animation_complete_system,
cleanup_completed_sprites_system,
update_tile_fade_system,
)
.chain()
.in_set(super::super::OverworldUpdate),
);
}
}
#[derive(Debug, Clone)]
pub struct ScaleInterpolator {
pub start: Vec3,
pub end: Vec3,
}
impl Interpolator for ScaleInterpolator {
type Item = Transform;
fn interpolate(&self, item: &mut Self::Item, value: f32, _previous_value: f32) {
item.scale = self.start.lerp(self.end, value);
}
}
fn apply_black_white_tilemap_material_system(
mut commands: Commands,
mut materials: ResMut<Assets<BlackWhiteTilemapMaterial>>,
tilemaps_query: Query<Entity, (With<TiledTilemap>, Without<HasBlackWhiteMaterial>)>,
) {
for entity in tilemaps_query.iter() {
let material_handle = materials.add(BlackWhiteTilemapMaterial {});
commands
.entity(entity)
.remove::<MaterialTilemapHandle<StandardTilemapMaterial>>()
.insert((
MaterialTilemapHandle(material_handle),
HasBlackWhiteMaterial,
));
}
}
fn cache_tilemap_textures_system(
mut texture_cache: ResMut<TilemapTextureCache>,
tilemaps_query: Query<(&TilemapTexture, &TilemapTileSize), With<TiledTilemap>>,
) {
if !texture_cache.textures.is_empty() {
return;
}
for (tilemap_texture, tile_size) in tilemaps_query.iter() {
texture_cache.tile_size = Vec2::new(tile_size.x, tile_size.y);
match tilemap_texture {
TilemapTexture::Single(handle) => {
if !texture_cache.textures.contains(handle) {
texture_cache.textures.push(handle.clone());
}
}
#[cfg(not(feature = "atlas"))]
TilemapTexture::Vector(handles) => {
for handle in handles {
if !texture_cache.textures.contains(handle) {
texture_cache.textures.push(handle.clone());
}
}
}
#[cfg(not(feature = "atlas"))]
TilemapTexture::TextureContainer(handle) => {
if !texture_cache.textures.contains(handle) {
texture_cache.textures.push(handle.clone());
}
}
}
}
}
fn initialize_tile_colors_system(
mut commands: Commands,
tiles_query: Query<Entity, (With<TiledTile>, Without<TileColor>)>,
) {
for entity in tiles_query.iter() {
commands
.entity(entity)
.insert(TileColor(Color::srgba(FADE_INITIAL, 1.0, 1.0, 1.0)));
}
}
fn hide_all_original_tiles_system(
mut reveal_state: ResMut<TileRevealState>,
mut tiles_query: Query<&mut TileVisible, With<TiledTile>>,
) {
if reveal_state.tiles_hidden {
return;
}
let mut hidden_count = 0;
for mut tile_visible in tiles_query.iter_mut() {
tile_visible.0 = false;
hidden_count += 1;
}
if hidden_count > 0 {
reveal_state.tiles_hidden = true;
info!("Hidden {} original tiles", hidden_count);
}
}
fn create_tile_sprites_system(
mut commands: Commands,
mut reveal_state: ResMut<TileRevealState>,
mut meshes: ResMut<Assets<Mesh>>,
mut color_materials: ResMut<Assets<ColorMaterial>>,
texture_cache: Res<TilemapTextureCache>,
tiled_maps_query: Query<&TiledMap>,
tiled_map_assets: Res<Assets<TiledMapAsset>>,
player_behavior: Option<Res<crate::app_state::overworld::player::config::PlayerBehavior>>,
existing_sprites: Query<Entity, With<RevealedTileSprite>>,
) {
if reveal_state.initialized || !existing_sprites.is_empty() {
return;
}
if texture_cache.textures.is_empty() || !reveal_state.tiles_hidden {
return;
}
let Ok(tiled_map_handle) = tiled_maps_query.single() else {
return;
};
let Some(tiled_map_asset) = tiled_map_assets.get(&tiled_map_handle.0) else {
return;
};
let origin = player_behavior
.map(|pb| pb.spawn_position)
.unwrap_or(Vec2::ZERO);
reveal_state.origin = origin;
let tile_width = tiled_map_asset.map.tile_width as f32;
let tile_height = tiled_map_asset.map.tile_height as f32;
let map_width = tiled_map_asset.map.width;
let map_height = tiled_map_asset.map.height;
let center_offset_x = -(map_width as f32 * tile_width) / 2.0;
let center_offset_y = -(map_height as f32 * tile_height) / 2.0;
let origin_tile_x = ((origin.x - center_offset_x) / tile_width).floor() as i32;
let origin_tile_y = ((origin.y - center_offset_y) / tile_height).floor() as i32;
let mut max_distance: u32 = 0;
let mut tile_positions: HashMap<(u32, u32), (Vec2, u32, RippleDirection)> = HashMap::new();
for layer in tiled_map_asset.map.layers() {
let Some(tile_layer) = layer.as_tile_layer() else {
continue;
};
let layer_name_lower = layer.name.to_ascii_lowercase();
if layer_name_lower.contains("prototype") || layer_name_lower.contains("collision") {
continue;
}
tiled_map_asset.for_each_tile(
&tile_layer,
|layer_tile, _tile_data, tile_pos, _chunk_pos| {
if layer_tile.get_tile().is_none() {
return;
}
let pos_key = (tile_pos.x, tile_pos.y);
let world_x =
center_offset_x + (tile_pos.x as f32 * tile_width) + (tile_width / 2.0);
let world_y =
center_offset_y + (tile_pos.y as f32 * tile_height) + (tile_height / 2.0);
let dx = tile_pos.x as i32 - origin_tile_x;
let dy = tile_pos.y as i32 - origin_tile_y;
let distance = (dx.abs() + dy.abs()) as u32;
max_distance = max_distance.max(distance);
let direction = if dx == 0 && dy == 0 {
RippleDirection::Up
} else if dx.abs() >= dy.abs() {
if dx > 0 {
RippleDirection::Right
} else {
RippleDirection::Left
}
} else if dy > 0 {
RippleDirection::Up
} else {
RippleDirection::Down
};
tile_positions.insert(pos_key, (Vec2::new(world_x, world_y), distance, direction));
},
);
}
let tile_mesh = meshes.add(Rectangle::new(tile_width + 0.5, tile_height + 0.5));
let white_material = color_materials.add(ColorMaterial::from_color(Color::WHITE));
let reveal_tiles_root = commands
.spawn((
Name::new("RevealTiles"),
RevealTilesRoot,
Transform::default(),
Visibility::Inherited,
super::super::OverworldEntity(),
))
.id();
let mut tile_count = 0;
for ((tile_x, tile_y), (world_pos, distance, direction)) in tile_positions.iter() {
let tile_entity = commands
.spawn((
Name::new(format!("RevealTile({},{})", tile_x, tile_y)),
RevealedTileSprite {
manhattan_distance: *distance,
tile_pos: (*tile_x, *tile_y),
direction: *direction,
},
Mesh2d(tile_mesh.clone()),
MeshMaterial2d(white_material.clone()),
Transform::from_xyz(world_pos.x, world_pos.y, -1.0).with_scale(Vec3::ZERO),
Visibility::Inherited,
ChildOf(reveal_tiles_root),
))
.id();
tile_count += 1;
let _ = tile_entity; }
reveal_state.max_distance = max_distance;
reveal_state.initialized = true;
info!(
"Tile reveal initialized: {} tiles, max distance: {}, origin: {:?}",
tile_count, max_distance, origin
);
}
fn collect_pending_tiles_system(
mut reveal_state: ResMut<TileRevealState>,
tiles_query: Query<
(Entity, &RevealedTileSprite),
(Without<AnimatingTile>, Without<AnimationComplete>),
>,
) {
if !reveal_state.initialized || reveal_state.all_triggered {
return;
}
if !reveal_state.pending_tiles_by_direction.is_empty() {
return;
}
for (entity, tile) in tiles_query.iter() {
if tile.manhattan_distance == reveal_state.current_step {
reveal_state
.pending_tiles_by_direction
.entry(tile.direction)
.or_default()
.push(entity);
}
}
}
fn update_reveal_animation_system(
mut commands: Commands,
mut reveal_state: ResMut<TileRevealState>,
mut beat_events: MessageReader<super::beat::BeatEvent>,
beat_tracker: Res<super::beat::BeatTracker>,
) {
if !reveal_state.initialized || reveal_state.all_triggered {
beat_events.clear();
return;
}
let mut should_step = false;
for event in beat_events.read() {
if matches!(event, super::beat::BeatEvent::QuarterNote) {
should_step = true;
break;
}
}
if !should_step {
return;
}
const TILES_PER_BEAT: usize = 8;
for tile_index in 0..TILES_PER_BEAT {
let all_directions = RippleDirection::all();
let available_directions: Vec<RippleDirection> = all_directions
.iter()
.filter(|d| !reveal_state.used_directions.contains(d))
.copied()
.collect();
let available_directions = if available_directions.is_empty() {
reveal_state.used_directions.clear();
all_directions.to_vec()
} else {
available_directions
};
let directions_with_tiles: Vec<RippleDirection> = available_directions
.iter()
.filter(|d| {
reveal_state
.pending_tiles_by_direction
.get(d)
.is_some_and(|v| !v.is_empty())
})
.copied()
.collect();
let directions_with_tiles = if directions_with_tiles.is_empty() {
let any_direction_with_tiles: Vec<RippleDirection> = reveal_state
.pending_tiles_by_direction
.iter()
.filter(|(_, v)| !v.is_empty())
.map(|(d, _)| *d)
.collect();
if any_direction_with_tiles.is_empty() {
reveal_state.current_step += 1;
reveal_state.pending_tiles_by_direction.clear();
if reveal_state.current_step > reveal_state.max_distance {
reveal_state.all_triggered = true;
info!("All tile reveal animations triggered");
}
return;
}
any_direction_with_tiles
} else {
directions_with_tiles
};
if directions_with_tiles.is_empty() {
break;
}
let pseudo_random_seed = (beat_tracker.counts.quarter as usize)
.wrapping_mul(73856093)
.wrapping_add((reveal_state.current_step as usize).wrapping_mul(19349663))
.wrapping_add(reveal_state.used_directions.len().wrapping_mul(83492791))
.wrapping_add(tile_index.wrapping_mul(47619417));
let index = pseudo_random_seed % directions_with_tiles.len();
let selected_direction = directions_with_tiles[index];
if !reveal_state.used_directions.contains(&selected_direction) {
reveal_state.used_directions.push(selected_direction);
}
let Some(tiles) = reveal_state
.pending_tiles_by_direction
.get_mut(&selected_direction)
else {
continue;
};
let Some(entity) = tiles.pop() else {
continue;
};
let animation_duration_ms = 500; let animation_duration = Duration::from_millis(animation_duration_ms);
commands.entity(entity).insert(AnimatingTile {
animation_timer: Timer::from_seconds(
animation_duration_ms as f32 / 1000.0,
TimerMode::Once,
),
});
commands.entity(entity).animation().insert_tween_here(
animation_duration,
EaseKind::BackOut,
entity.into_target().with(ScaleInterpolator {
start: Vec3::ZERO,
end: Vec3::ONE,
}),
);
}
let all_empty = reveal_state
.pending_tiles_by_direction
.values()
.all(|v| v.is_empty());
if all_empty {
reveal_state.current_step += 1;
reveal_state.pending_tiles_by_direction.clear();
if reveal_state.current_step > reveal_state.max_distance {
reveal_state.all_triggered = true;
info!("All tile reveal animations triggered");
}
}
}
fn check_animation_complete_system(
mut commands: Commands,
time: Res<Time>,
mut animating_tiles: Query<(Entity, &mut AnimatingTile), Without<AnimationComplete>>,
) {
for (entity, mut animating) in animating_tiles.iter_mut() {
animating.animation_timer.tick(time.delta());
if animating.animation_timer.is_finished() {
commands.entity(entity).insert(AnimationComplete);
}
}
}
fn cleanup_completed_sprites_system(
mut commands: Commands,
completed_sprites: Query<(Entity, &RevealedTileSprite), With<AnimationComplete>>,
mut tiles_query: Query<
(Entity, &TilePos, &mut TileVisible),
(With<TiledTile>, Without<TileFadeState>),
>,
) {
for (sprite_entity, reveal_sprite) in completed_sprites.iter() {
for (tile_entity, tile_pos, mut tile_visible) in tiles_query.iter_mut() {
if tile_pos.x == reveal_sprite.tile_pos.0 && tile_pos.y == reveal_sprite.tile_pos.1 {
tile_visible.0 = true;
let hash =
(tile_pos.x.wrapping_mul(73856093)) ^ (tile_pos.y.wrapping_mul(19349663));
let random_offset = hash % 8;
commands.entity(tile_entity).insert(TileFadeState {
fade: FADE_INITIAL,
random_offset,
eighth_notes_elapsed: 0,
});
}
}
commands.entity(sprite_entity).despawn();
}
}
fn update_tile_fade_system(
mut beat_events: MessageReader<super::beat::BeatEvent>,
mut tiles_query: Query<(&mut TileColor, &mut TileFadeState), With<TiledTile>>,
) {
let mut eighth_note_count = 0u32;
for event in beat_events.read() {
if matches!(event, super::beat::BeatEvent::EighthNote) {
eighth_note_count += 1;
}
}
if eighth_note_count == 0 {
return;
}
let step_size = (FADE_INITIAL - FADE_TARGET) / FADE_EIGHTH_NOTES as f32;
for (mut tile_color, mut fade_state) in tiles_query.iter_mut() {
if fade_state.fade <= FADE_TARGET {
continue;
}
fade_state.eighth_notes_elapsed += eighth_note_count;
if fade_state.eighth_notes_elapsed <= fade_state.random_offset {
continue;
}
let effective_steps = fade_state.eighth_notes_elapsed - fade_state.random_offset;
let target_fade = (FADE_INITIAL - step_size * effective_steps as f32).max(FADE_TARGET);
fade_state.fade = target_fade;
tile_color.0 = Color::srgba(target_fade, 1.0, 1.0, 1.0);
}
}