1use crate::docks::data_undo::*;
2use crate::editor::RUSTERIX;
3use crate::prelude::*;
4use rusterix::PixelSource;
5use rusterix::avatar_builder::AvatarRuntimeBuilder;
6use rusterix::server::data::{apply_entity_data, apply_item_data};
7use theframework::prelude::*;
8use theframework::theui::thewidget::thetextedit::TheTextEditState;
9use toml::Table;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13pub enum EntityKey {
14 RegionSettings(Uuid),
15 Character(Uuid),
16 CharacterPreviewRigging(Uuid),
17 Item(Uuid),
18 ProjectSettings,
19 GameRules,
20 GameLocales,
21 GameAudioFx,
22 ScreenWidget(Uuid, Uuid), }
24
25#[derive(Clone, Debug)]
26struct CharacterPreviewRigging {
27 animation: Option<String>,
28 perspective: AvatarDirection,
29 fixed_frame: usize,
30 play: bool,
31 speed: f32,
32 debug: bool,
33 slots: FxHashMap<String, String>,
34 slot_overrides: FxHashMap<String, CharacterPreviewSlotOverride>,
35 attrs: FxHashMap<String, Value>,
36}
37
38#[derive(Clone, Debug, Default)]
39struct CharacterPreviewSlotOverride {
40 rig_scale: Option<f32>,
41 rig_pivot: Option<[f32; 2]>,
42 rig_layer: Option<String>,
43}
44
45pub struct DataDock {
46 entity_undos: FxHashMap<EntityKey, DataUndo>,
48 current_entity: Option<EntityKey>,
49 max_undo: usize,
50 prev_state: Option<TheTextEditState>,
51 validation_signatures: FxHashMap<EntityKey, String>,
52}
53
54impl Dock for DataDock {
55 fn new() -> Self
56 where
57 Self: Sized,
58 {
59 Self {
60 entity_undos: FxHashMap::default(),
61 current_entity: None,
62 max_undo: 30,
63 prev_state: None,
64 validation_signatures: FxHashMap::default(),
65 }
66 }
67
68 fn setup(&mut self, _ctx: &mut TheContext) -> TheCanvas {
69 let mut center = TheCanvas::new();
70
71 let mut toolbar_canvas = TheCanvas::default();
72 toolbar_canvas.set_widget(TheTraybar::new(TheId::empty()));
73 let mut toolbar_hlayout = TheHLayout::new(TheId::empty());
74 toolbar_hlayout.set_background_color(None);
75 toolbar_hlayout.set_margin(Vec4::new(10, 1, 5, 1));
76 toolbar_hlayout.set_padding(3);
77
78 let mut play = TheTraybarButton::new(TheId::named("Audio FX Preview Play"));
79 play.set_text("Play".to_string());
80 play.set_status_text("Preview the audio effect under the cursor");
81 toolbar_hlayout.add_widget(Box::new(play));
82
83 toolbar_canvas.set_layout(toolbar_hlayout);
84 center.set_top(toolbar_canvas);
85
86 let mut textedit = TheTextAreaEdit::new(TheId::named("DockDataEditor"));
87 if let Some(bytes) = crate::Embedded::get("parser/TOML.sublime-syntax") {
88 if let Ok(source) = std::str::from_utf8(bytes.data.as_ref()) {
89 textedit.add_syntax_from_string(source);
90 textedit.set_code_type("TOML");
91 }
92 }
93
94 if let Some(bytes) = crate::Embedded::get("parser/gruvbox-dark.tmTheme") {
95 if let Ok(source) = std::str::from_utf8(bytes.data.as_ref()) {
96 textedit.add_theme_from_string(source);
97 textedit.set_code_theme("Gruvbox Dark");
98 }
99 }
100
101 textedit.set_continuous(true);
102 textedit.display_line_number(true);
103 textedit.use_global_statusbar(true);
105 textedit.set_font_size(14.0);
106 textedit.set_supports_undo(false);
108 center.set_widget(textedit);
109
110 center
111 }
112
113 fn activate(
114 &mut self,
115 ui: &mut TheUI,
116 ctx: &mut TheContext,
117 project: &Project,
118 server_ctx: &mut ServerContext,
119 ) {
120 if let Some(id) = server_ctx.pc.id() {
121 if server_ctx.pc.is_region() {
122 if let Some(region) = project.get_region(&id) {
123 ui.set_widget_value(
124 "DockDataEditor",
125 ctx,
126 TheValue::Text(region.config.clone()),
127 );
128 self.switch_to_entity(EntityKey::RegionSettings(id), ctx);
130 }
131 } else if server_ctx.pc.is_character() {
132 if let Some(character) = project.characters.get(&id) {
133 match server_ctx.pc {
134 ProjectContext::CharacterPreviewRigging(_) => {
135 ui.set_widget_value(
136 "DockDataEditor",
137 ctx,
138 TheValue::Text(character.preview_rigging.clone()),
139 );
140 self.switch_to_entity(EntityKey::CharacterPreviewRigging(id), ctx);
141 }
142 _ => {
143 ui.set_widget_value(
144 "DockDataEditor",
145 ctx,
146 TheValue::Text(character.data.clone()),
147 );
148 self.switch_to_entity(EntityKey::Character(id), ctx);
149 }
150 }
151 }
152 } else if server_ctx.pc.is_item() {
153 if let Some(item) = project.items.get(&id) {
154 ui.set_widget_value("DockDataEditor", ctx, TheValue::Text(item.data.clone()));
155 self.switch_to_entity(EntityKey::Item(id), ctx);
157 }
158 } else if let ProjectContext::ScreenWidget(screen_id, widget_id) = server_ctx.pc {
159 if let Some(screen) = project.screens.get(&screen_id) {
160 for sector in &screen.map.sectors {
161 if sector.creator_id == widget_id {
162 let data = sector.properties.get_str_default("data", "".into());
163 ui.set_widget_value("DockDataEditor", ctx, TheValue::Text(data));
164 self.switch_to_entity(
166 EntityKey::ScreenWidget(screen_id, widget_id),
167 ctx,
168 );
169 break;
170 }
171 }
172 }
173 }
174 } else if server_ctx.pc.is_project_settings() {
175 ui.set_widget_value(
176 "DockDataEditor",
177 ctx,
178 TheValue::Text(project.config.clone()),
179 );
180 self.switch_to_entity(EntityKey::ProjectSettings, ctx);
182 } else if server_ctx.pc.is_game_rules() {
183 ui.set_widget_value("DockDataEditor", ctx, TheValue::Text(project.rules.clone()));
184 self.switch_to_entity(EntityKey::GameRules, ctx);
185 } else if server_ctx.pc.is_game_locales() {
186 ui.set_widget_value(
187 "DockDataEditor",
188 ctx,
189 TheValue::Text(project.locales.clone()),
190 );
191 self.switch_to_entity(EntityKey::GameLocales, ctx);
192 } else if server_ctx.pc.is_game_audio_fx() {
193 ui.set_widget_value(
194 "DockDataEditor",
195 ctx,
196 TheValue::Text(project.audio_fx.clone()),
197 );
198 self.switch_to_entity(EntityKey::GameAudioFx, ctx);
199 }
200
201 self.sync_audio_fx_toolbar(ctx, server_ctx);
202 self.validate_project_documents(project);
203
204 if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
206 self.prev_state = Some(edit.get_state());
207 }
208 }
209
210 fn handle_event(
211 &mut self,
212 event: &TheEvent,
213 ui: &mut TheUI,
214 ctx: &mut TheContext,
215 project: &mut Project,
216 server_ctx: &mut ServerContext,
217 ) -> bool {
218 let mut redraw = false;
219
220 match event {
221 TheEvent::ValueChanged(id, value) => {
222 if id.name == "DockDataEditor" {
223 if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
224 if let Some(prev) = &self.prev_state {
226 let current_state = edit.get_state();
227 let atom = DataUndoAtom::TextEdit(prev.clone(), current_state.clone());
228 self.add_undo(atom, ctx);
229 self.prev_state = Some(current_state);
230 }
231 }
232
233 if let Some(id) = server_ctx.pc.id() {
234 if server_ctx.pc.is_region() {
235 if let Some(code) = value.to_string() {
236 if let Some(region) = project.get_region_mut(&id) {
237 region.config = code;
238 redraw = true;
239 }
240 }
241 if let Ok(changed) =
242 crate::utils::update_region_settings(project, server_ctx)
243 {
244 if changed {
245 ctx.ui.send(TheEvent::Custom(
246 TheId::named("Update Minimap"),
247 TheValue::Empty,
248 ));
249
250 RUSTERIX.write().unwrap().set_dirty();
251
252 ctx.ui.send(TheEvent::Custom(
253 TheId::named("Render SceneManager Map"),
254 TheValue::Empty,
255 ));
256 }
257 }
258 } else if server_ctx.pc.is_character() {
259 if let Some(code) = value.to_string() {
260 if let Some(character) = project.characters.get_mut(&id) {
261 match server_ctx.pc {
262 ProjectContext::CharacterPreviewRigging(_) => {
263 character.preview_rigging = code;
264 ctx.ui.send(TheEvent::Custom(
265 TheId::named("Update Minimap"),
266 TheValue::Empty,
267 ));
268 }
269 _ => character.data = code,
270 }
271 redraw = true;
272 }
273 }
274 } else if server_ctx.pc.is_item() {
275 if let Some(code) = value.to_string() {
276 if let Some(item) = project.items.get_mut(&id) {
277 item.data = code;
278 redraw = true;
279 }
280 }
281 } else if let ProjectContext::ScreenWidget(screen_id, widget_id) =
282 server_ctx.pc
283 {
284 if let Some(code) = value.to_string() {
285 if let Some(screen) = project.screens.get_mut(&screen_id) {
286 for sector in &mut screen.map.sectors {
287 if sector.creator_id == widget_id {
288 sector.properties.set("data".into(), Value::Str(code));
289 redraw = true;
290 break;
291 }
292 }
293 }
294 }
295 }
296 } else if server_ctx.pc.is_project_settings() {
297 if let Some(code) = value.to_string() {
298 _ = RUSTERIX.write().unwrap().scene_handler.settings.read(&code);
299 project.config = code;
300 redraw = true;
301 }
302 } else if server_ctx.pc.is_game_rules() {
303 if let Some(code) = value.to_string() {
304 project.rules = code;
305 redraw = true;
306 }
307 } else if server_ctx.pc.is_game_locales() {
308 if let Some(code) = value.to_string() {
309 project.locales = code;
310 redraw = true;
311 }
312 } else if server_ctx.pc.is_game_audio_fx() {
313 if let Some(code) = value.to_string() {
314 project.audio_fx = code;
315 redraw = true;
316 let mut rusterix = RUSTERIX.write().unwrap();
317 rusterix.assets.audio_fx_src = project.audio_fx.clone();
318 rusterix.load_audio_assets();
319 }
320 }
321
322 self.validate_project_documents(project);
323 }
324 }
325 TheEvent::StateChanged(id, state) => {
326 if *state == TheWidgetState::Clicked {
327 if id.name == "Audio FX Preview Play" {
328 self.preview_audio_fx(ui, project);
329 }
330 }
331 }
332 _ => {}
333 }
334 redraw
335 }
336
337 fn draw_minimap(
338 &self,
339 buffer: &mut TheRGBABuffer,
340 project: &Project,
341 ctx: &mut TheContext,
342 server_ctx: &ServerContext,
343 ) -> bool {
344 let ProjectContext::CharacterPreviewRigging(character_id) = server_ctx.pc else {
345 return false;
346 };
347 let Some(character) = project.characters.get(&character_id) else {
348 return false;
349 };
350
351 let mut entity = rusterix::Entity::default();
352 apply_entity_data(&mut entity, &character.data);
353
354 let preview = Self::parse_preview_rigging(&character.preview_rigging);
355 if preview.debug {
356 eprintln!(
357 "[RIGPREVIEW] active char={} anim='{}' perspective={:?} play={} speed={} slots={} overrides={} attrs={}",
358 character_id,
359 preview.animation.as_deref().unwrap_or("<first>"),
360 preview.perspective,
361 preview.play,
362 preview.speed,
363 preview.slots.len(),
364 preview.slot_overrides.len(),
365 preview.attrs.len()
366 );
367 }
368 Self::populate_preview_equipment(&preview, project, &mut entity);
369
370 let Some(avatar) = Self::find_preview_avatar(&entity, project) else {
371 buffer.fill(BLACK);
372 return true;
373 };
374
375 let frame_index = Self::preview_frame_index(avatar, &preview, server_ctx.animation_counter);
376 let mut assets = rusterix::Assets::new();
377 assets.palette = project.palette.clone();
378 assets.tiles = project.tiles.clone();
379
380 let out = AvatarRuntimeBuilder::build_preview_for_entity(
381 &entity,
382 avatar,
383 &assets,
384 preview.animation.as_deref(),
385 preview.perspective,
386 frame_index,
387 rusterix::AvatarShadingOptions::default(),
388 );
389
390 buffer.fill(BLACK);
391 let Some(out) = out else {
392 if preview.debug {
393 eprintln!(
394 "[RIGPREVIEW] build failed anim='{}' perspective={:?} frame={}",
395 preview.animation.as_deref().unwrap_or("<first>"),
396 preview.perspective,
397 frame_index
398 );
399 }
400 return true;
401 };
402
403 let src_w = out.size as usize;
404 let src_h = out.size as usize;
405 if src_w == 0 || src_h == 0 {
406 return true;
407 }
408
409 let dim = buffer.dim();
410 let dst_w = dim.width as f32;
411 let dst_h = dim.height as f32;
412 let scale = (dst_w / src_w as f32).min(dst_h / src_h as f32);
413 let draw_w = (src_w as f32 * scale).round().max(1.0) as usize;
414 let draw_h = (src_h as f32 * scale).round().max(1.0) as usize;
415 let offset_x = ((dst_w as usize).saturating_sub(draw_w)) / 2;
416 let offset_y = ((dst_h as usize).saturating_sub(draw_h)) / 2;
417 let dst_rect = (offset_x, offset_y, draw_w, draw_h);
418
419 let stride = buffer.stride();
420 ctx.draw.blend_scale_chunk(
421 buffer.pixels_mut(),
422 &dst_rect,
423 stride,
424 &out.rgba,
425 &(src_w, src_h),
426 );
427
428 true
429 }
430
431 fn supports_minimap_animation(&self) -> bool {
432 true
433 }
434
435 fn supports_undo(&self) -> bool {
436 true
437 }
438
439 fn has_changes(&self) -> bool {
440 self.entity_undos.values().any(|undo| undo.has_changes())
442 }
443
444 fn mark_saved(&mut self) {
445 for undo in self.entity_undos.values_mut() {
446 undo.index = -1;
447 }
448 }
449
450 fn undo(
451 &mut self,
452 ui: &mut TheUI,
453 ctx: &mut TheContext,
454 project: &mut Project,
455 server_ctx: &mut ServerContext,
456 ) {
457 if let Some(entity_key) = self.current_entity {
458 if let Some(undo) = self.entity_undos.get_mut(&entity_key) {
459 if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
460 undo.undo(edit);
461 self.prev_state = Some(edit.get_state());
462 self.set_undo_state_to_ui(ctx);
463
464 self.update_project_data(ui, ctx, project, server_ctx);
466 }
467 }
468 }
469 }
470
471 fn redo(
472 &mut self,
473 ui: &mut TheUI,
474 ctx: &mut TheContext,
475 project: &mut Project,
476 server_ctx: &mut ServerContext,
477 ) {
478 if let Some(entity_key) = self.current_entity {
479 if let Some(undo) = self.entity_undos.get_mut(&entity_key) {
480 if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
481 undo.redo(edit);
482 self.prev_state = Some(edit.get_state());
483 self.set_undo_state_to_ui(ctx);
484
485 self.update_project_data(ui, ctx, project, server_ctx);
487 }
488 }
489 }
490 }
491
492 fn set_undo_state_to_ui(&self, ctx: &mut TheContext) {
493 if let Some(entity_key) = self.current_entity {
494 if let Some(undo) = self.entity_undos.get(&entity_key) {
495 if undo.has_undo() {
496 ctx.ui.set_enabled("Undo");
497 } else {
498 ctx.ui.set_disabled("Undo");
499 }
500
501 if undo.has_redo() {
502 ctx.ui.set_enabled("Redo");
503 } else {
504 ctx.ui.set_disabled("Redo");
505 }
506 return;
507 }
508 }
509
510 ctx.ui.set_disabled("Undo");
512 ctx.ui.set_disabled("Redo");
513 }
514}
515
516impl DataDock {
517 fn sync_audio_fx_toolbar(&mut self, ctx: &mut TheContext, server_ctx: &ServerContext) {
518 let active = server_ctx.pc.is_game_audio_fx();
519 for id in ["Audio FX Preview Play"] {
520 if active {
521 ctx.ui.set_enabled(id);
522 } else {
523 ctx.ui.set_disabled(id);
524 }
525 }
526 }
527
528 fn preview_audio_fx(&mut self, ui: &mut TheUI, project: &Project) {
529 let Some(effect_name) = self.current_audio_fx_name(ui) else {
530 return;
531 };
532 let Ok(bytes) = rusterix::audio::synthesize_audio_fx_wav(&project.audio_fx, &effect_name)
533 else {
534 return;
535 };
536
537 let mut rusterix = RUSTERIX.write().unwrap();
538 if rusterix.audio.is_none() {
539 rusterix.audio = rusterix::AudioEngine::new().ok();
540 }
541 let Some(engine) = rusterix.audio.as_ref() else {
542 return;
543 };
544 engine.clear_bus("preview");
545 let clip_name = "__audio_fx_preview";
546 let _ = engine.load_clip_from_bytes(clip_name, &bytes);
547 let _ = engine.play_on_bus(clip_name, "preview", 1.0, false);
548 }
549
550 fn current_audio_fx_name(&self, ui: &mut TheUI) -> Option<String> {
551 let edit = ui.get_text_area_edit("DockDataEditor")?;
552 let state = edit.get_state();
553 let row = state.cursor.row.min(state.rows.len().saturating_sub(1));
554
555 for index in (0..=row).rev() {
556 let line = state.rows.get(index)?.trim();
557 if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
558 let section = section.trim();
559 if let Some(name) = section.strip_prefix("sfx.") {
560 let name = name.trim();
561 if !name.is_empty() {
562 return Some(name.to_string());
563 }
564 }
565 }
566 }
567 None
568 }
569
570 fn is_preview_slot_key(key: &str) -> bool {
571 matches!(
572 key.to_ascii_lowercase().as_str(),
573 "main_hand"
574 | "mainhand"
575 | "weapon"
576 | "weapon_main"
577 | "hand_main"
578 | "off_hand"
579 | "offhand"
580 | "weapon_off"
581 | "hand_off"
582 | "shield"
583 )
584 }
585
586 fn parse_preview_rigging(toml_src: &str) -> CharacterPreviewRigging {
587 let mut out = CharacterPreviewRigging {
588 animation: None,
589 perspective: AvatarDirection::Front,
590 fixed_frame: 0,
591 play: true,
592 speed: 1.0,
593 debug: false,
594 slots: FxHashMap::default(),
595 slot_overrides: FxHashMap::default(),
596 attrs: FxHashMap::default(),
597 };
598
599 let Ok(table) = toml_src.parse::<Table>() else {
600 return out;
601 };
602
603 out.animation = table
604 .get("animation")
605 .and_then(toml::Value::as_str)
606 .map(ToString::to_string);
607 if let Some(dir) = table.get("perspective").and_then(toml::Value::as_str) {
608 out.perspective = match dir.to_ascii_lowercase().as_str() {
609 "back" => AvatarDirection::Back,
610 "left" => AvatarDirection::Left,
611 "right" => AvatarDirection::Right,
612 _ => AvatarDirection::Front,
613 };
614 }
615 out.fixed_frame = table
616 .get("frame")
617 .and_then(toml::Value::as_integer)
618 .unwrap_or(0)
619 .max(0) as usize;
620 out.play = table
621 .get("play")
622 .and_then(toml::Value::as_bool)
623 .unwrap_or(true);
624 out.speed = table
625 .get("speed")
626 .and_then(toml::Value::as_float)
627 .unwrap_or(1.0)
628 .max(0.01) as f32;
629 out.debug = table
630 .get("debug")
631 .and_then(toml::Value::as_bool)
632 .unwrap_or(false);
633
634 for (key, value) in &table {
636 if matches!(
637 key.as_str(),
638 "animation"
639 | "perspective"
640 | "frame"
641 | "play"
642 | "speed"
643 | "debug"
644 | "slots"
645 | "slot_overrides"
646 ) {
647 continue;
648 }
649 if Self::is_preview_slot_key(key)
650 && let Some(item_ref) = value.as_str()
651 {
652 out.slots.insert(key.to_string(), item_ref.to_string());
653 continue;
654 }
655 if let Some(v) = Self::toml_to_attr_value(value) {
656 out.attrs.insert(key.to_string(), v);
657 }
658 }
659
660 if let Some(slots) = table.get("slots").and_then(toml::Value::as_table) {
661 for (slot, value) in slots {
662 if let Some(v) = value.as_str() {
663 out.slots.insert(slot.to_string(), v.to_string());
664 } else if let Some(v) = Self::toml_to_attr_value(value) {
665 out.attrs.insert(slot.to_string(), v);
667 }
668 }
669 }
670
671 if let Some(overrides) = table.get("slot_overrides").and_then(toml::Value::as_table) {
672 for (slot, value) in overrides {
673 let Some(slot_table) = value.as_table() else {
674 continue;
675 };
676 let mut slot_override = CharacterPreviewSlotOverride::default();
677 if let Some(scale) = slot_table.get("rig_scale").and_then(toml::Value::as_float) {
678 slot_override.rig_scale = Some(scale as f32);
679 }
680 if let Some(pivot) = slot_table.get("rig_pivot").and_then(toml::Value::as_array)
681 && pivot.len() == 2
682 && let (Some(x), Some(y)) = (pivot[0].as_float(), pivot[1].as_float())
683 {
684 slot_override.rig_pivot = Some([x as f32, y as f32]);
685 }
686 if let Some(layer) = slot_table.get("rig_layer").and_then(toml::Value::as_str) {
687 slot_override.rig_layer = Some(layer.to_string());
688 }
689 if slot_override.rig_scale.is_some()
690 || slot_override.rig_pivot.is_some()
691 || slot_override.rig_layer.is_some()
692 {
693 out.slot_overrides.insert(slot.to_string(), slot_override);
694 }
695 }
696 }
697
698 out
699 }
700
701 fn find_preview_avatar<'a>(
702 entity: &rusterix::Entity,
703 project: &'a Project,
704 ) -> Option<&'a Avatar> {
705 if let Some(avatar_id) = entity.attributes.get_id("avatar_id")
706 && let Some(avatar) = project.avatars.get(&avatar_id)
707 {
708 return Some(avatar);
709 }
710 if let Some(name) = entity.attributes.get_str("avatar") {
711 for avatar in project.avatars.values() {
712 if avatar.name.eq_ignore_ascii_case(name) {
713 return Some(avatar);
714 }
715 }
716 }
717 project.avatars.values().next()
718 }
719
720 fn find_item_template<'a>(project: &'a Project, ident: &str) -> Option<&'a Item> {
721 project.items.values().find(|item| {
722 if item.name.eq_ignore_ascii_case(ident) {
723 return true;
724 }
725
726 let mut parsed = rusterix::Item::default();
727 apply_item_data(&mut parsed, &item.data);
728 if parsed
729 .attributes
730 .get_str("name")
731 .map(|name| name.eq_ignore_ascii_case(ident))
732 .unwrap_or(false)
733 {
734 return true;
735 }
736
737 if let Ok(table) = item.data.parse::<Table>() {
739 return table
740 .get("name")
741 .and_then(toml::Value::as_str)
742 .map(|name| name.eq_ignore_ascii_case(ident))
743 .unwrap_or(false);
744 }
745 false
746 })
747 }
748
749 fn apply_preview_item_top_level(item: &mut rusterix::Item, toml_src: &str) {
750 let Ok(table) = toml_src.parse::<Table>() else {
751 return;
752 };
753 for key in [
754 "tile_id",
755 "tile_id_front",
756 "tile_id_back",
757 "tile_id_left",
758 "tile_id_right",
759 ] {
760 if let Some(id) = table.get(key).and_then(toml::Value::as_str)
761 && let Ok(uuid) = Uuid::parse_str(id)
762 {
763 item.attributes
764 .set(key, Value::Source(PixelSource::TileId(uuid)));
765 }
766 }
767 if let Some(scale) = table.get("rig_scale").and_then(toml::Value::as_float) {
768 item.attributes.set("rig_scale", Value::Float(scale as f32));
769 }
770 if let Some(pivot) = table.get("rig_pivot").and_then(toml::Value::as_array)
771 && pivot.len() == 2
772 && let (Some(x), Some(y)) = (pivot[0].as_float(), pivot[1].as_float())
773 {
774 item.attributes
775 .set("rig_pivot", Value::Vec2([x as f32, y as f32]));
776 }
777 if let Some(slot) = table.get("slot").and_then(toml::Value::as_str) {
778 item.attributes.set("slot", Value::Str(slot.to_string()));
779 }
780 if let Some(layer) = table.get("rig_layer").and_then(toml::Value::as_str) {
781 item.attributes
782 .set("rig_layer", Value::Str(layer.to_string()));
783 }
784 }
785
786 fn populate_preview_equipment(
787 preview: &CharacterPreviewRigging,
788 project: &Project,
789 entity: &mut rusterix::Entity,
790 ) {
791 entity.equipped.clear();
792 entity
793 .attributes
794 .set("avatar_preview_debug", Value::Bool(preview.debug));
795 for (key, value) in &preview.attrs {
796 entity.attributes.set(key, value.clone());
797 }
798 for (slot, item_ref) in &preview.slots {
799 let Some(template) = Self::find_item_template(project, item_ref) else {
800 if preview.debug {
801 eprintln!(
802 "[RIGPREVIEW] slot='{}' item='{}' -> NOT FOUND",
803 slot, item_ref
804 );
805 }
806 continue;
807 };
808 let mut runtime_item = rusterix::Item::default();
809 apply_item_data(&mut runtime_item, &template.data);
810 Self::apply_preview_item_top_level(&mut runtime_item, &template.data);
811 runtime_item
812 .attributes
813 .set("slot", Value::Str(slot.to_string()));
814 if let Some(override_cfg) = preview.slot_overrides.get(slot) {
815 if let Some(scale) = override_cfg.rig_scale {
816 runtime_item
817 .attributes
818 .set("rig_scale", Value::Float(scale.max(0.01)));
819 }
820 if let Some(pivot) = override_cfg.rig_pivot {
821 runtime_item.attributes.set("rig_pivot", Value::Vec2(pivot));
822 }
823 if let Some(layer) = &override_cfg.rig_layer {
824 runtime_item
825 .attributes
826 .set("rig_layer", Value::Str(layer.clone()));
827 }
828 }
829 if preview.debug {
830 let has_tile = runtime_item
831 .attributes
832 .get_source("source")
833 .or_else(|| runtime_item.attributes.get_source("tile_id"))
834 .or_else(|| runtime_item.attributes.get_source("tile_id_front"))
835 .or_else(|| runtime_item.attributes.get_source("tile_id_back"))
836 .or_else(|| runtime_item.attributes.get_source("tile_id_left"))
837 .or_else(|| runtime_item.attributes.get_source("tile_id_right"))
838 .is_some();
839 eprintln!(
840 "[RIGPREVIEW] slot='{}' item='{}' -> FOUND name='{}' tile={} override_scale={:?} override_pivot={:?} override_layer={:?}",
841 slot,
842 item_ref,
843 template.name,
844 has_tile,
845 preview.slot_overrides.get(slot).and_then(|o| o.rig_scale),
846 preview.slot_overrides.get(slot).and_then(|o| o.rig_pivot),
847 preview
848 .slot_overrides
849 .get(slot)
850 .and_then(|o| o.rig_layer.clone())
851 );
852 }
853 entity.equipped.insert(slot.to_string(), runtime_item);
854 }
855 }
856
857 fn toml_to_attr_value(value: &toml::Value) -> Option<Value> {
858 if let Some(v) = value.as_integer() {
859 return Some(Value::Int(v as i32));
860 }
861 if let Some(v) = value.as_float() {
862 return Some(Value::Float(v as f32));
863 }
864 if let Some(v) = value.as_bool() {
865 return Some(Value::Bool(v));
866 }
867 if let Some(v) = value.as_str() {
868 return Some(Value::Str(v.to_string()));
869 }
870 None
871 }
872
873 fn preview_frame_index(
874 avatar: &Avatar,
875 preview: &CharacterPreviewRigging,
876 animation_counter: usize,
877 ) -> usize {
878 let Some(anim) = preview
879 .animation
880 .as_deref()
881 .and_then(|name| {
882 avatar
883 .animations
884 .iter()
885 .find(|a| a.name.eq_ignore_ascii_case(name))
886 })
887 .or_else(|| avatar.animations.first())
888 else {
889 return preview.fixed_frame;
890 };
891 let frame_count = anim
892 .perspectives
893 .iter()
894 .find(|p| p.direction == preview.perspective)
895 .or_else(|| {
896 anim.perspectives
897 .iter()
898 .find(|p| p.direction == AvatarDirection::Front)
899 })
900 .or_else(|| anim.perspectives.first())
901 .map(|p| p.frames.len().max(1))
902 .unwrap_or(1);
903
904 if preview.play {
905 ((animation_counter as f32 / preview.speed).floor() as usize) % frame_count
906 } else {
907 preview.fixed_frame % frame_count
908 }
909 }
910
911 fn switch_to_entity(&mut self, entity_key: EntityKey, ctx: &mut TheContext) {
913 self.current_entity = Some(entity_key);
914 self.set_undo_state_to_ui(ctx);
915 }
916
917 fn validate_project_documents(&mut self, project: &Project) {
918 let Some(entity_key) = self.current_entity else {
919 return;
920 };
921
922 if !matches!(
923 entity_key,
924 EntityKey::GameRules | EntityKey::GameLocales | EntityKey::GameAudioFx
925 ) {
926 return;
927 }
928
929 let issues = Self::collect_project_validation_issues(project);
930 let signature = issues.join("\n");
931 let previous = self
932 .validation_signatures
933 .insert(entity_key, signature.clone())
934 .unwrap_or_default();
935
936 if signature == previous || issues.is_empty() {
937 return;
938 }
939
940 let label = match entity_key {
941 EntityKey::GameRules => "Game / Rules",
942 EntityKey::GameLocales => "Game / Locales",
943 EntityKey::GameAudioFx => "Game / Audio FX",
944 _ => return,
945 };
946
947 let mut chunk = format!("[Warning] {} validation\n", label);
948 for issue in issues {
949 chunk.push_str("- ");
950 chunk.push_str(&issue);
951 chunk.push('\n');
952 }
953
954 let mut rusterix = RUSTERIX.write().unwrap();
955 rusterix.server.log.push_str(&chunk);
956 rusterix.server.log_changed = true;
957 }
958
959 fn collect_project_validation_issues(project: &Project) -> Vec<String> {
960 let mut issues = Vec::new();
961
962 let locale_tables = match Self::parse_locale_tables(&project.locales) {
963 Ok(locales) => locales,
964 Err(err) => {
965 issues.push(format!("Locales TOML parse error: {}", err));
966 FxHashMap::default()
967 }
968 };
969
970 let (audio_fx_names, audio_fx_issues) =
971 Self::parse_audio_fx_names_and_issues(&project.audio_fx);
972 issues.extend(audio_fx_issues);
973
974 let asset_audio_names = project
975 .assets
976 .values()
977 .filter(|asset| matches!(asset.buffer, AssetBuffer::Audio(_)))
978 .map(|asset| asset.name.clone())
979 .collect::<FxHashSet<_>>();
980
981 match project.rules.parse::<Table>() {
982 Ok(rules) => {
983 let referenced_locale_keys = Self::rules_locale_keys(&rules);
984 let referenced_audio_fx = Self::rules_audio_fx_refs(&rules);
985
986 if locale_tables.is_empty() {
987 for key in &referenced_locale_keys {
988 issues.push(format!(
989 "Rules reference locale key '{}' but Game / Locales has no locale tables.",
990 key
991 ));
992 }
993 } else {
994 for locale in locale_tables.keys() {
995 let keys = locale_tables.get(locale).unwrap();
996 for key in &referenced_locale_keys {
997 if !keys.contains(key) {
998 issues
999 .push(format!("Locale '{}' is missing key '{}'.", locale, key));
1000 }
1001 }
1002 }
1003 }
1004
1005 for (path, name) in referenced_audio_fx {
1006 if !audio_fx_names.contains(&name) && !asset_audio_names.contains(&name) {
1007 issues.push(format!(
1008 "Rules reference unknown audio '{}' at '{}'.",
1009 name, path
1010 ));
1011 }
1012 }
1013 }
1014 Err(err) => issues.push(format!("Rules TOML parse error: {}", err)),
1015 }
1016
1017 issues
1018 }
1019
1020 fn parse_locale_tables(src: &str) -> Result<FxHashMap<String, FxHashSet<String>>, String> {
1021 let table = src.parse::<Table>().map_err(|err| err.to_string())?;
1022 let mut locales = FxHashMap::default();
1023 for (locale, value) in table {
1024 let Some(locale_table) = value.as_table() else {
1025 continue;
1026 };
1027 let mut keys = FxHashSet::default();
1028 Self::flatten_locale_keys("", locale_table, &mut keys);
1029 locales.insert(locale, keys);
1030 }
1031 Ok(locales)
1032 }
1033
1034 fn flatten_locale_keys(prefix: &str, table: &Table, out: &mut FxHashSet<String>) {
1035 for (key, value) in table {
1036 let full = if prefix.is_empty() {
1037 key.clone()
1038 } else {
1039 format!("{}.{}", prefix, key)
1040 };
1041 if let Some(nested) = value.as_table() {
1042 Self::flatten_locale_keys(&full, nested, out);
1043 } else {
1044 out.insert(full);
1045 }
1046 }
1047 }
1048
1049 fn parse_audio_fx_names_and_issues(src: &str) -> (FxHashSet<String>, Vec<String>) {
1050 const ALLOWED_PARAMS: &[&str] = &[
1051 "wave",
1052 "duration",
1053 "attack",
1054 "decay",
1055 "sustain_level",
1056 "release",
1057 "gain",
1058 "freq",
1059 "freq_end",
1060 "noise",
1061 "lowpass",
1062 "repeat",
1063 "repeat_gap",
1064 "tremolo_depth",
1065 "tremolo_freq",
1066 ];
1067 const ALLOWED_WAVES: &[&str] = &["sine", "square", "saw", "triangle", "noise"];
1068
1069 let table = match src.parse::<Table>() {
1070 Ok(table) => table,
1071 Err(err) => {
1072 return (
1073 FxHashSet::default(),
1074 vec![format!("Audio FX TOML parse error: {}", err)],
1075 );
1076 }
1077 };
1078
1079 let mut names = FxHashSet::default();
1080 let mut issues = Vec::new();
1081
1082 let Some(sfx) = table.get("sfx").and_then(toml::Value::as_table) else {
1083 return (names, issues);
1084 };
1085
1086 for (name, value) in sfx {
1087 let Some(effect) = value.as_table() else {
1088 issues.push(format!("Audio FX section 'sfx.{}' must be a table.", name));
1089 continue;
1090 };
1091 names.insert(name.clone());
1092
1093 for key in effect.keys() {
1094 if !ALLOWED_PARAMS.contains(&key.as_str()) {
1095 issues.push(format!(
1096 "Audio FX 'sfx.{}' uses unknown parameter '{}'.",
1097 name, key
1098 ));
1099 }
1100 }
1101
1102 if let Some(wave) = effect.get("wave").and_then(toml::Value::as_str)
1103 && !ALLOWED_WAVES.contains(&wave)
1104 {
1105 issues.push(format!(
1106 "Audio FX 'sfx.{}' uses unsupported wave '{}'.",
1107 name, wave
1108 ));
1109 }
1110 }
1111
1112 (names, issues)
1113 }
1114
1115 fn rules_locale_keys(rules: &Table) -> Vec<String> {
1116 let mut keys = Vec::new();
1117 if let Some(messages) = rules
1118 .get("combat")
1119 .and_then(toml::Value::as_table)
1120 .and_then(|combat| combat.get("messages"))
1121 .and_then(toml::Value::as_table)
1122 {
1123 for key in ["incoming_key", "outgoing_key"] {
1124 if let Some(value) = messages
1125 .get(key)
1126 .and_then(toml::Value::as_str)
1127 .filter(|value| !value.trim().is_empty())
1128 {
1129 keys.push(value.to_string());
1130 }
1131 }
1132 }
1133 keys
1134 }
1135
1136 fn rules_audio_fx_refs(rules: &Table) -> Vec<(String, String)> {
1137 let mut refs = Vec::new();
1138
1139 if let Some(audio) = rules
1140 .get("combat")
1141 .and_then(toml::Value::as_table)
1142 .and_then(|combat| combat.get("audio"))
1143 .and_then(toml::Value::as_table)
1144 {
1145 for key in ["incoming_fx", "outgoing_fx"] {
1146 if let Some(name) = audio
1147 .get(key)
1148 .and_then(toml::Value::as_str)
1149 .filter(|value| !value.trim().is_empty())
1150 {
1151 refs.push((format!("combat.audio.{}", key), name.to_string()));
1152 }
1153 }
1154 }
1155
1156 if let Some(kinds) = rules
1157 .get("combat")
1158 .and_then(toml::Value::as_table)
1159 .and_then(|combat| combat.get("kinds"))
1160 .and_then(toml::Value::as_table)
1161 {
1162 for (kind, value) in kinds {
1163 let Some(kind_audio) = value
1164 .as_table()
1165 .and_then(|kind_table| kind_table.get("audio"))
1166 .and_then(toml::Value::as_table)
1167 else {
1168 continue;
1169 };
1170 for key in ["incoming_fx", "outgoing_fx"] {
1171 if let Some(name) = kind_audio
1172 .get(key)
1173 .and_then(toml::Value::as_str)
1174 .filter(|value| !value.trim().is_empty())
1175 {
1176 refs.push((
1177 format!("combat.kinds.{}.audio.{}", kind, key),
1178 name.to_string(),
1179 ));
1180 }
1181 }
1182 }
1183 }
1184
1185 refs
1186 }
1187
1188 fn add_undo(&mut self, atom: DataUndoAtom, ctx: &mut TheContext) {
1190 if let Some(entity_key) = self.current_entity {
1191 let undo = self
1192 .entity_undos
1193 .entry(entity_key)
1194 .or_insert_with(DataUndo::new);
1195 undo.add(atom);
1196 undo.truncate_to_limit(self.max_undo);
1197 self.set_undo_state_to_ui(ctx);
1198 }
1199 }
1200
1201 fn update_project_data(
1203 &mut self,
1204 ui: &mut TheUI,
1205 ctx: &mut TheContext,
1206 project: &mut Project,
1207 server_ctx: &mut ServerContext,
1208 ) {
1209 if let Some(id) = server_ctx.pc.id() {
1210 if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1211 let state = edit.get_state();
1212 let text = state.rows.join("\n");
1213
1214 if server_ctx.pc.is_region() {
1215 if let Some(region) = project.get_region_mut(&id) {
1216 region.config = text;
1217 if let Ok(changed) =
1218 crate::utils::update_region_settings(project, server_ctx)
1219 {
1220 if changed {
1221 ctx.ui.send(TheEvent::Custom(
1222 TheId::named("Update Minimap"),
1223 TheValue::Empty,
1224 ));
1225
1226 RUSTERIX.write().unwrap().set_dirty();
1227
1228 ctx.ui.send(TheEvent::Custom(
1229 TheId::named("Render SceneManager Map"),
1230 TheValue::Empty,
1231 ));
1232 }
1233 }
1234 }
1235 } else if server_ctx.pc.is_character() {
1236 if let Some(character) = project.characters.get_mut(&id) {
1237 match server_ctx.pc {
1238 ProjectContext::CharacterPreviewRigging(_) => {
1239 character.preview_rigging = text;
1240 ctx.ui.send(TheEvent::Custom(
1241 TheId::named("Update Minimap"),
1242 TheValue::Empty,
1243 ));
1244 }
1245 _ => character.data = text,
1246 }
1247 }
1248 } else if server_ctx.pc.is_item() {
1249 if let Some(item) = project.items.get_mut(&id) {
1250 item.data = text;
1251 }
1252 } else if let ProjectContext::ScreenWidget(screen_id, widget_id) = server_ctx.pc {
1253 if let Some(screen) = project.screens.get_mut(&screen_id) {
1254 for sector in &mut screen.map.sectors {
1255 if sector.creator_id == widget_id {
1256 sector.properties.set("data".into(), Value::Str(text));
1257 break;
1258 }
1259 }
1260 }
1261 }
1262 }
1263 } else if server_ctx.pc.is_project_settings() {
1264 if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1265 let state = edit.get_state();
1266 let text = state.rows.join("\n");
1267 _ = RUSTERIX.write().unwrap().scene_handler.settings.read(&text);
1268 project.config = text;
1269 }
1270 } else if server_ctx.pc.is_game_rules() {
1271 if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1272 let state = edit.get_state();
1273 let text = state.rows.join("\n");
1274 project.rules = text;
1275 }
1276 } else if server_ctx.pc.is_game_locales() {
1277 if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1278 let state = edit.get_state();
1279 let text = state.rows.join("\n");
1280 project.locales = text;
1281 }
1282 } else if server_ctx.pc.is_game_audio_fx() {
1283 if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1284 let state = edit.get_state();
1285 let text = state.rows.join("\n");
1286 project.audio_fx = text;
1287 }
1288 }
1289
1290 self.validate_project_documents(project);
1291 }
1292}