1use bevy::asset::{AssetMetaCheck, RecursiveDependencyLoadState};
2use bevy::camera::RenderTarget;
3use bevy::prelude::*;
4use bevy::render::render_resource::TextureFormat;
5use bevy_ecs_tiled::prelude::*;
6use bevy_egui::{EguiContexts, EguiTextureHandle};
7use bevy_workbench::console::console_log_layer;
8use bevy_workbench::i18n::{I18n, Locale};
9use bevy_workbench::prelude::*;
10use serde::{Deserialize, Serialize};
11
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::sync::RwLock;
15use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
16
17mod panels;
18
19use panels::{MapDetailsPanel, MapListPanel, MapPreviewPanel};
20
21#[derive(Clone, Debug)]
25pub struct MapSection {
26 pub name: String,
28 pub key: String,
30 pub default_visible: bool,
32}
33
34#[derive(Clone, Debug)]
36pub struct MapCategory {
37 pub name: String,
39 pub key: String,
41}
42
43#[derive(Clone, Debug, Default, Serialize, Deserialize)]
45pub struct MapBadge {
46 pub label: String,
47 #[serde(default)]
48 pub tone: Option<String>,
49}
50
51#[derive(Clone, Debug, Default, Serialize, Deserialize)]
53pub struct MapDetail {
54 pub label: String,
55 pub value: String,
56}
57
58#[derive(Clone, Debug, Default, Serialize, Deserialize)]
60pub struct MapManifestEntry {
61 pub path: String,
62 pub title: String,
63 #[serde(default)]
64 pub section: Option<String>,
65 #[serde(default)]
66 pub category: Option<String>,
67 #[serde(default)]
68 pub badges: Vec<MapBadge>,
69 #[serde(default)]
70 pub details: Vec<MapDetail>,
71}
72
73impl MapManifestEntry {
74 pub fn display_title(&self) -> &str {
75 if self.title.is_empty() {
76 &self.path
77 } else {
78 &self.title
79 }
80 }
81}
82
83#[derive(Clone, Debug, Default, Serialize, Deserialize)]
85pub struct MapManifest {
86 #[serde(default)]
87 pub maps: Vec<MapManifestEntry>,
88}
89
90pub struct ViewerConfig {
92 pub title: String,
94 pub resolution: (u32, u32),
96 pub sections: Vec<MapSection>,
98 pub categories: Vec<MapCategory>,
101 pub manifest_path: String,
103 pub locale_sources: Vec<(Locale, &'static str)>,
105}
106
107impl Default for ViewerConfig {
108 fn default() -> Self {
109 Self {
110 title: "Tiled Map Web Viewer".into(),
111 resolution: (1280, 720),
112 sections: vec![],
113 categories: vec![],
114 manifest_path: "assets/manifest.json".into(),
115 locale_sources: vec![],
116 }
117 }
118}
119
120pub fn run(config: ViewerConfig) {
122 let mut app = App::new();
123
124 let dev_toggle = Arc::new(AtomicBool::new(false));
125 let section_toggles = Arc::new(RwLock::new(
126 config
127 .sections
128 .iter()
129 .map(|section| SectionToggle {
130 key: section.key.clone(),
131 name: section.name.clone(),
132 visible: section.default_visible,
133 })
134 .collect::<Vec<_>>(),
135 ));
136 app.add_plugins(
137 DefaultPlugins
138 .set(AssetPlugin {
139 meta_check: AssetMetaCheck::Never,
140 ..default()
141 })
142 .set(WindowPlugin {
143 primary_window: Some(Window {
144 title: config.title.clone(),
145 resolution: (config.resolution.0, config.resolution.1).into(),
146 canvas: Some("#the_canvas_id".to_string()),
147 fit_canvas_to_parent: true,
148 prevent_default_event_handling: true,
149 ..default()
150 }),
151 ..default()
152 })
153 .set(ImagePlugin::default_nearest())
154 .set(bevy::log::LogPlugin {
155 custom_layer: console_log_layer,
156 ..default()
157 }),
158 )
159 .insert_resource(ClearColor(Color::BLACK))
160 .add_plugins(WorkbenchPlugin {
161 config: WorkbenchConfig {
162 show_toolbar: false,
163 enable_game_view: false,
164 ..default()
165 },
166 })
167 .add_plugins(TiledPlugin::default());
168
169 {
171 let mut i18n = app.world_mut().resource_mut::<I18n>();
172 i18n.add_custom_source(Locale::En, include_str!("../locales/en.ftl"));
173 i18n.add_custom_source(Locale::ZhCn, include_str!("../locales/zh-CN.ftl"));
174 for (locale, ftl) in &config.locale_sources {
175 i18n.add_custom_source(*locale, *ftl);
176 }
177 }
178
179 let shared_translations: SharedTranslations = {
180 let i18n = app.world().resource::<I18n>();
181 Arc::new(RwLock::new(Translations::from_i18n(i18n)))
182 };
183
184 let sections = config.sections.clone();
186 let categories = config.categories.clone();
187 let manifest_path = config.manifest_path.clone();
188
189 app.init_resource::<MapLoadRequest>()
190 .init_resource::<MapLoadingState>()
191 .init_resource::<PreviewInput>()
192 .init_resource::<CameraZoomState>()
193 .init_resource::<DevWindowsEnabled>()
194 .insert_resource(SectionVisibilityState::from_sections(&config.sections))
195 .init_resource::<SelectedMapDetails>()
196 .init_resource::<ScrollSensitivity>()
197 .init_resource::<MenuBarExtensions>()
198 .add_systems(Startup, setup)
199 .add_systems(
200 Update,
201 (
202 handle_map_load,
203 track_map_loading,
204 sync_preview_to_panel,
205 apply_camera_zoom,
206 apply_camera_pan,
207 handle_dev_menu_actions,
208 notify_web_loader_ready,
209 ),
210 )
211 .add_systems(
212 bevy_egui::EguiPrimaryContextPass,
213 sync_dev_menu.before(bevy_workbench::menu_bar::menu_bar_system),
214 );
215
216 let t_for_list = shared_translations.clone();
218 app.register_panel(MapListPanel::new(
219 t_for_list,
220 sections,
221 categories,
222 manifest_path,
223 ));
224 let t_for_preview = shared_translations.clone();
225 app.register_panel(MapPreviewPanel::new(t_for_preview));
226 let t_for_details = shared_translations.clone();
227 app.register_panel(MapDetailsPanel::new(t_for_details));
228
229 {
231 let mut tile_state = app
232 .world_mut()
233 .resource_mut::<bevy_workbench::dock::TileLayoutState>();
234 tile_state.hide_from_window_menu("workbench_inspector");
235 tile_state.hide_from_window_menu("workbench_console");
236 tile_state.set_default_hidden("workbench_inspector");
237 tile_state.set_default_hidden("workbench_console");
238 }
239
240 let toggle_for_settings = dev_toggle.clone();
242 let t_for_dev_section = shared_translations.clone();
243 app.register_settings_section(SettingsSection {
244 label: shared_translations.read().unwrap().developer_label.clone(),
245 ui_fn: Box::new(move |ui| {
246 let label = t_for_dev_section.read().unwrap().allow_dev_windows.clone();
247 let mut val = toggle_for_settings.load(Ordering::Relaxed);
248 if ui.checkbox(&mut val, label).changed() {
249 toggle_for_settings.store(val, Ordering::Relaxed);
250 }
251 }),
252 });
253
254 if !config.sections.is_empty() {
255 let toggles_for_settings = section_toggles.clone();
256 let t_for_map_section = shared_translations.clone();
257 app.register_settings_section(SettingsSection {
258 label: shared_translations
259 .read()
260 .unwrap()
261 .map_sections_label
262 .clone(),
263 ui_fn: Box::new(move |ui| {
264 let Ok(t) = t_for_map_section.read() else {
265 return;
266 };
267 ui.label(&t.settings_visible_sections_hint);
268 drop(t);
269
270 let Ok(mut toggles) = toggles_for_settings.write() else {
271 return;
272 };
273 for toggle in toggles.iter_mut() {
274 ui.checkbox(&mut toggle.visible, &toggle.name);
275 }
276 }),
277 });
278 }
279
280 let zoom_sens = Arc::new(AtomicU32::new(0.01_f32.to_bits()));
282 let pan_sens = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
283 let zoom_for_settings = zoom_sens.clone();
284 let pan_for_settings = pan_sens.clone();
285 let t_for_sens_section = shared_translations.clone();
286 app.register_settings_section(SettingsSection {
287 label: shared_translations
288 .read()
289 .unwrap()
290 .sensitivity_label
291 .clone(),
292 ui_fn: Box::new(move |ui| {
293 let t = t_for_sens_section.read().unwrap();
294 let mut zoom_val = f32::from_bits(zoom_for_settings.load(Ordering::Relaxed));
295 if ui
296 .add(egui::Slider::new(&mut zoom_val, 0.001..=0.05).text(&t.zoom_sensitivity))
297 .changed()
298 {
299 zoom_for_settings.store(zoom_val.to_bits(), Ordering::Relaxed);
300 }
301
302 let mut pan_val = f32::from_bits(pan_for_settings.load(Ordering::Relaxed));
303 if ui
304 .add(egui::Slider::new(&mut pan_val, 0.1..=3.0).text(&t.pan_sensitivity))
305 .changed()
306 {
307 pan_for_settings.store(pan_val.to_bits(), Ordering::Relaxed);
308 }
309 }),
310 });
311
312 let toggle_for_system = dev_toggle.clone();
314 let section_toggles_for_system = section_toggles.clone();
315 let zoom_for_system = zoom_sens.clone();
316 let pan_for_system = pan_sens.clone();
317 let t_for_sync = shared_translations.clone();
318 app.add_systems(
319 Update,
320 move |mut dev_enabled: ResMut<DevWindowsEnabled>,
321 mut section_visibility: ResMut<SectionVisibilityState>,
322 mut sensitivity: ResMut<ScrollSensitivity>,
323 i18n: Res<I18n>| {
324 dev_enabled.0 = toggle_for_system.load(Ordering::Relaxed);
325 section_visibility.0.clear();
326 if let Ok(toggles) = section_toggles_for_system.read() {
327 for toggle in toggles.iter() {
328 section_visibility
329 .0
330 .insert(toggle.key.clone(), toggle.visible);
331 }
332 }
333 sensitivity.zoom = f32::from_bits(zoom_for_system.load(Ordering::Relaxed));
334 sensitivity.pan = f32::from_bits(pan_for_system.load(Ordering::Relaxed));
335
336 if i18n.is_changed()
337 && let Ok(mut t) = t_for_sync.write()
338 {
339 *t = Translations::from_i18n(&i18n);
340 }
341 },
342 );
343
344 app.run();
345}
346
347#[derive(Clone, Default)]
350#[allow(dead_code)]
351struct Translations {
352 allow_dev_windows: String,
353 zoom_sensitivity: String,
354 pan_sensitivity: String,
355 developer_label: String,
356 sensitivity_label: String,
357 map_sections_label: String,
358 settings_visible_sections_hint: String,
359 dev_windows_menu: String,
360 inspector: String,
361 console: String,
362 map_list: String,
363 map_preview: String,
364 map_details: String,
365 list_loading_maps: String,
366 list_no_maps: String,
367 list_other_group: String,
368 details_no_selection: String,
369 details_path: String,
370 details_section: String,
371 details_category: String,
372 details_badges: String,
373 loading_cleanup: String,
374 loading_textures: String,
375 loading_spawning: String,
376}
377
378impl Translations {
379 fn from_i18n(i18n: &I18n) -> Self {
380 Self {
381 allow_dev_windows: i18n.t("settings-allow-dev-windows"),
382 zoom_sensitivity: i18n.t("settings-zoom-sensitivity"),
383 pan_sensitivity: i18n.t("settings-pan-sensitivity"),
384 developer_label: i18n.t("settings-developer"),
385 sensitivity_label: i18n.t("settings-sensitivity"),
386 map_sections_label: i18n.t("settings-map-sections"),
387 settings_visible_sections_hint: i18n.t("settings-visible-sections-hint"),
388 dev_windows_menu: i18n.t("menu-dev-windows"),
389 inspector: i18n.t("menu-dev-inspector"),
390 console: i18n.t("menu-dev-console"),
391 map_list: i18n.t("panel-map-list"),
392 map_preview: i18n.t("panel-map-preview"),
393 map_details: i18n.t("panel-map-details"),
394 list_loading_maps: i18n.t("list-loading-maps"),
395 list_no_maps: i18n.t("list-no-maps"),
396 list_other_group: i18n.t("list-other-group"),
397 details_no_selection: i18n.t("details-no-selection"),
398 details_path: i18n.t("details-path"),
399 details_section: i18n.t("details-section"),
400 details_category: i18n.t("details-category"),
401 details_badges: i18n.t("details-badges"),
402 loading_cleanup: i18n.t("loading-cleanup"),
403 loading_textures: i18n.t("loading-textures"),
404 loading_spawning: i18n.t("loading-spawning"),
405 }
406 }
407}
408
409pub(crate) type SharedTranslations = Arc<RwLock<Translations>>;
410
411#[derive(Resource, Default)]
412struct MapLoadRequest {
413 map_to_load: Option<String>,
414}
415
416#[derive(Resource, Default)]
417struct MapLoadingState {
418 phase: LoadPhase,
419 current_map: Option<String>,
420 pending_handle: Option<Handle<TiledMapAsset>>,
421 status_text: String,
422}
423
424#[derive(Default, PartialEq)]
425enum LoadPhase {
426 #[default]
427 Idle,
428 Cleanup,
429 LoadingAssets,
430 Spawning,
431}
432
433#[derive(Resource)]
434struct MapPreviewState {
435 render_target: Handle<Image>,
436 egui_texture_id: Option<egui::TextureId>,
437 width: u32,
438 height: u32,
439}
440
441#[derive(Resource, Default)]
442struct PreviewInput {
443 scroll_delta: f32,
444 drag_delta: egui::Vec2,
445 #[allow(dead_code)]
446 hovered: bool,
447 cursor_uv: Option<egui::Pos2>,
448 image_screen_size: egui::Vec2,
449}
450
451#[derive(Resource)]
452struct CameraZoomState {
453 current_scale: f32,
454 target_scale: f32,
455}
456
457impl Default for CameraZoomState {
458 fn default() -> Self {
459 Self {
460 current_scale: 4.0,
461 target_scale: 4.0,
462 }
463 }
464}
465
466#[derive(Resource, Default)]
467struct DevWindowsEnabled(bool);
468
469#[derive(Resource, Default)]
470struct SectionVisibilityState(HashMap<String, bool>);
471
472impl SectionVisibilityState {
473 fn from_sections(sections: &[MapSection]) -> Self {
474 Self(
475 sections
476 .iter()
477 .map(|section| (section.key.clone(), section.default_visible))
478 .collect(),
479 )
480 }
481}
482
483#[derive(Resource, Default)]
484struct SelectedMapDetails(Option<MapManifestEntry>);
485
486#[derive(Clone)]
487struct SectionToggle {
488 key: String,
489 name: String,
490 visible: bool,
491}
492
493#[derive(Resource)]
494struct ScrollSensitivity {
495 zoom: f32,
496 pan: f32,
497}
498
499impl Default for ScrollSensitivity {
500 fn default() -> Self {
501 Self {
502 zoom: 0.01,
503 pan: 1.0,
504 }
505 }
506}
507
508#[derive(Component)]
509struct PreviewCamera;
510
511fn setup(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
514 let width = 1920u32;
515 let height = 1080u32;
516
517 let image = Image::new_target_texture(width, height, TextureFormat::Rgba8UnormSrgb, None);
518 let render_target = images.add(image);
519
520 commands.spawn(Camera2d);
521 commands.spawn((
522 Camera2d,
523 Camera {
524 order: -1,
525 clear_color: ClearColorConfig::Custom(Color::srgb(0.1, 0.1, 0.15)),
526 ..default()
527 },
528 RenderTarget::from(render_target.clone()),
529 PreviewCamera,
530 ));
531
532 commands.insert_resource(MapPreviewState {
533 render_target,
534 egui_texture_id: None,
535 width,
536 height,
537 });
538}
539
540fn sync_dev_menu(
541 dev_enabled: Res<DevWindowsEnabled>,
542 i18n: Res<I18n>,
543 mut extensions: ResMut<MenuBarExtensions>,
544 tile_state: Res<bevy_workbench::dock::TileLayoutState>,
545) {
546 let inspector_visible = tile_state.is_panel_visible("workbench_inspector");
547 let console_visible = tile_state.is_panel_visible("workbench_console");
548
549 let inspector_label = i18n.t("menu-dev-inspector");
550 let console_label = i18n.t("menu-dev-console");
551
552 extensions.custom_menus = vec![CustomMenu {
553 id: "dev_windows",
554 label: i18n.t("menu-dev-windows"),
555 enabled: dev_enabled.0,
556 items: vec![
557 MenuExtItem {
558 id: "toggle_inspector",
559 label: if inspector_visible {
560 format!("✓ {inspector_label}")
561 } else {
562 format!(" {inspector_label}")
563 },
564 enabled: true,
565 },
566 MenuExtItem {
567 id: "toggle_console",
568 label: if console_visible {
569 format!("✓ {console_label}")
570 } else {
571 format!(" {console_label}")
572 },
573 enabled: true,
574 },
575 ],
576 }];
577}
578
579fn handle_dev_menu_actions(
580 mut menu_actions: MessageReader<MenuAction>,
581 mut tile_state: ResMut<bevy_workbench::dock::TileLayoutState>,
582) {
583 for action in menu_actions.read() {
584 match action.id {
585 "toggle_inspector" => {
586 if tile_state.is_panel_visible("workbench_inspector") {
587 tile_state.close_panel("workbench_inspector");
588 } else {
589 tile_state.request_open_panel("workbench_inspector");
590 }
591 }
592 "toggle_console" => {
593 if tile_state.is_panel_visible("workbench_console") {
594 tile_state.close_panel("workbench_console");
595 } else {
596 tile_state.request_open_panel("workbench_console");
597 }
598 }
599 _ => {}
600 }
601 }
602}
603
604fn handle_map_load(
605 mut commands: Commands,
606 mut load_request: ResMut<MapLoadRequest>,
607 mut loading: ResMut<MapLoadingState>,
608 i18n: Res<I18n>,
609 existing_maps: Query<Entity, With<TiledMap>>,
610) {
611 let Some(map_name) = load_request.map_to_load.take() else {
612 return;
613 };
614
615 for entity in &existing_maps {
616 commands.entity(entity).despawn();
617 }
618
619 loading.phase = LoadPhase::Cleanup;
620 loading.current_map = Some(map_name);
621 loading.pending_handle = None;
622 loading.status_text = i18n.t("loading-cleanup");
623}
624
625fn track_map_loading(
626 mut commands: Commands,
627 mut loading: ResMut<MapLoadingState>,
628 asset_server: Res<AssetServer>,
629 i18n: Res<I18n>,
630 maps: Query<&Children, With<TiledMap>>,
631) {
632 match loading.phase {
633 LoadPhase::Idle => {}
634 LoadPhase::Cleanup => {
635 if let Some(ref map_name) = loading.current_map.clone() {
636 let handle: Handle<TiledMapAsset> = asset_server.load(map_name);
637 loading.pending_handle = Some(handle);
638 loading.phase = LoadPhase::LoadingAssets;
639 loading.status_text = i18n.t("loading-textures");
640 info!("Loading map: {}", map_name);
641 }
642 }
643 LoadPhase::LoadingAssets => {
644 if let Some(ref handle) = loading.pending_handle {
645 let load_state = asset_server.recursive_dependency_load_state(handle);
646 match load_state {
647 RecursiveDependencyLoadState::Loaded => {
648 commands.spawn((TiledMap(handle.clone()), TilemapAnchor::Center));
649 loading.phase = LoadPhase::Spawning;
650 loading.status_text = i18n.t("loading-spawning");
651 }
652 RecursiveDependencyLoadState::Failed(_) => {
653 error!("Failed to load map assets");
654 loading.phase = LoadPhase::Idle;
655 loading.status_text.clear();
656 }
657 _ => {
658 loading.status_text = i18n.t("loading-textures");
659 }
660 }
661 }
662 }
663 LoadPhase::Spawning => {
664 for children in &maps {
665 if !children.is_empty() {
666 loading.phase = LoadPhase::Idle;
667 loading.status_text.clear();
668 loading.pending_handle = None;
669 }
670 }
671 }
672 }
673}
674
675fn apply_camera_zoom(
676 mut zoom_state: ResMut<CameraZoomState>,
677 input: Res<PreviewInput>,
678 preview: Res<MapPreviewState>,
679 sensitivity: Res<ScrollSensitivity>,
680 time: Res<Time>,
681 mut camera_q: Query<(&mut Transform, &mut Projection), With<PreviewCamera>>,
682) {
683 let Ok((mut transform, mut projection)) = camera_q.single_mut() else {
684 return;
685 };
686 let Projection::Orthographic(ref mut ortho) = *projection else {
687 return;
688 };
689
690 if input.scroll_delta.abs() > 0.001 {
691 let zoom_factor = 1.0 - input.scroll_delta * sensitivity.zoom;
692 zoom_state.target_scale = (zoom_state.target_scale * zoom_factor).clamp(0.2, 30.0);
693 }
694
695 let lerp_speed = 12.0;
696 let dt = time.delta_secs();
697 let old_scale = zoom_state.current_scale;
698 zoom_state.current_scale +=
699 (zoom_state.target_scale - zoom_state.current_scale) * (1.0 - (-lerp_speed * dt).exp());
700 ortho.scale = zoom_state.current_scale;
701
702 if let Some(uv) = input.cursor_uv
703 && (old_scale - zoom_state.current_scale).abs() > 0.0001
704 {
705 let rt_w = preview.width as f32;
706 let rt_h = preview.height as f32;
707 let cx = uv.x - 0.5;
708 let cy = -(uv.y - 0.5);
709
710 let world_offset_x = cx * rt_w * old_scale;
711 let world_offset_y = cy * rt_h * old_scale;
712 let new_world_offset_x = cx * rt_w * zoom_state.current_scale;
713 let new_world_offset_y = cy * rt_h * zoom_state.current_scale;
714
715 transform.translation.x += world_offset_x - new_world_offset_x;
716 transform.translation.y += world_offset_y - new_world_offset_y;
717 }
718}
719
720fn apply_camera_pan(
721 mut input: ResMut<PreviewInput>,
722 zoom_state: Res<CameraZoomState>,
723 sensitivity: Res<ScrollSensitivity>,
724 preview: Res<MapPreviewState>,
725 mut camera_q: Query<&mut Transform, With<PreviewCamera>>,
726) {
727 let Ok(mut transform) = camera_q.single_mut() else {
728 return;
729 };
730
731 if input.drag_delta.length_sq() > 0.001 {
732 let img_w = input.image_screen_size.x.max(1.0);
733 let img_h = input.image_screen_size.y.max(1.0);
734 let scale_x = preview.width as f32 / img_w * zoom_state.current_scale;
735 let scale_y = preview.height as f32 / img_h * zoom_state.current_scale;
736
737 transform.translation.x -= input.drag_delta.x * scale_x * sensitivity.pan;
738 transform.translation.y += input.drag_delta.y * scale_y * sensitivity.pan;
739 }
740
741 input.scroll_delta = 0.0;
742 input.drag_delta = egui::Vec2::ZERO;
743}
744
745fn sync_preview_to_panel(
746 mut state: ResMut<MapPreviewState>,
747 mut contexts: EguiContexts,
748 mut tile_state: ResMut<bevy_workbench::dock::TileLayoutState>,
749 loading: Res<MapLoadingState>,
750 mut input: ResMut<PreviewInput>,
751 mut images: ResMut<Assets<Image>>,
752) {
753 if state.egui_texture_id.is_none() && state.render_target != Handle::default() {
754 let texture_id = contexts.add_image(EguiTextureHandle::Strong(state.render_target.clone()));
755 state.egui_texture_id = Some(texture_id);
756 }
757
758 if let Some(panel) = tile_state.get_panel_mut::<MapPreviewPanel>("map_preview") {
759 panel.egui_texture_id = state.egui_texture_id;
760 panel.is_loading = loading.phase != LoadPhase::Idle;
761 panel.loading_status = loading.status_text.clone();
762
763 let panel_w = (panel.panel_size.x as u32).max(2);
764 let panel_h = (panel.panel_size.y as u32).max(2);
765 if panel_w > 0
766 && panel_h > 0
767 && (panel_w != state.width || panel_h != state.height)
768 && panel.panel_size.x > 10.0
769 {
770 let new_image =
771 Image::new_target_texture(panel_w, panel_h, TextureFormat::Rgba8UnormSrgb, None);
772 if let Some(img) = images.get_mut(&state.render_target) {
773 *img = new_image;
774 }
775 state.width = panel_w;
776 state.height = panel_h;
777 }
778
779 panel.width = state.width;
780 panel.height = state.height;
781
782 input.scroll_delta += panel.pending_scroll;
783 input.drag_delta += panel.pending_drag;
784 input.hovered = panel.is_hovered;
785 input.cursor_uv = panel.cursor_uv;
786 input.image_screen_size = panel.image_screen_size;
787
788 panel.pending_scroll = 0.0;
789 panel.pending_drag = egui::Vec2::ZERO;
790 }
791}
792
793#[cfg(target_arch = "wasm32")]
794fn notify_web_loader_ready(mut notified: Local<bool>) {
795 if *notified {
796 return;
797 }
798
799 let Some(window) = web_sys::window() else {
800 return;
801 };
802
803 let Ok(event) = web_sys::Event::new("bevy-app-ready") else {
804 return;
805 };
806
807 if window.dispatch_event(&event).is_ok() {
808 *notified = true;
809 }
810}
811
812#[cfg(not(target_arch = "wasm32"))]
813fn notify_web_loader_ready() {}