use concinnity_core::ecs::World;
use crate::editor::behavior::chart::{self, ChartIds};
use crate::editor::behavior::graph::{Card, Chart};
use crate::editor::hud_ids::{hud_ids, panel_base};
use crate::editor::panels::registry::PanelKey;
use crate::editor::widget::{self, point_in};
hud_ids! {
base: panel_base(PanelKey::Map);
sprites: [PANEL_BG, CLOSE_BG, ..CHART_IDS.sprite_ids()];
labels: [TITLE_LABEL, CLOSE_LABEL, ..CHART_IDS.label_ids()];
blocks: [CHART_BASE: ChartIds::SPAN];
}
pub(crate) const CHART_IDS: ChartIds = ChartIds::new(CHART_BASE);
const COLUMNS: usize = 3;
const ROWS: usize = 4;
const CHROME_H: f32 = widget::TITLE_H;
const OVERFLOW: &str = "places in view -- pan to reach the rest";
pub(crate) struct MapView<'a> {
pub chart: &'a Chart,
pub selected: Option<usize>,
pub pan: [f32; 2],
pub mouse: [f32; 2],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MapAction {
Select(usize),
PanStart,
Consume,
}
pub(crate) fn size() -> [f32; 2] {
[
chart::width_for(COLUMNS),
CHROME_H + chart::height_for(ROWS),
]
}
pub(crate) fn default_origin(vw: f32) -> [f32; 2] {
[
((vw - size()[0]) * 0.5).max(8.0),
crate::editor::hud::body_top() + 48.0,
]
}
fn body_top(o: [f32; 2]) -> f32 {
o[1] + CHROME_H
}
pub(crate) fn band(o: [f32; 2], s: [f32; 2]) -> [f32; 4] {
chart::band(o, s, body_top(o))
}
pub(crate) fn canvas(s: [f32; 2]) -> [f32; 2] {
let b = band([0.0, 0.0], s);
[b[2], b[3]]
}
pub(crate) fn cursor_over_body(mx: f32, my: f32, o: [f32; 2], s: [f32; 2]) -> bool {
let p = widget::outer_rect(o, s);
mx >= p[0] && mx < p[0] + p[2] && my >= body_top(o) && my < p[1] + p[3]
}
pub(crate) fn hit_test(
view: &MapView,
mx: f32,
my: f32,
o: [f32; 2],
s: [f32; 2],
) -> Option<MapAction> {
let band = band(o, s);
if point_in(mx, my, band) {
return Some(match chart::hit_card(&chart_view(view), mx, my, band) {
Some(i) if view.chart.cards[i].handle.is_some() => MapAction::Select(i),
Some(_) => MapAction::Consume,
None => MapAction::PanStart,
});
}
point_in(mx, my, widget::outer_rect(o, s)).then_some(MapAction::Consume)
}
fn root_card(chart: &Chart) -> Option<&Card> {
chart.cards.iter().min_by_key(|c| (c.column, c.row))
}
pub(crate) fn root_pan(chart: &Chart, canvas: [f32; 2]) -> [f32; 2] {
match root_card(chart) {
Some(card) => chart::pan_to(card, canvas, [0.0, 0.0], chart),
None => [0.0, 0.0],
}
}
pub(crate) fn place(world: &mut World, view: Option<&MapView>, o: [f32; 2], s: [f32; 2]) {
let Some(view) = view else {
ids().hide(world);
return;
};
widget::place_panel(world, PANEL_BG, widget::outer_rect(o, s));
let title = widget::title_rect(o, s[0]);
widget::place_heading(world, TITLE_LABEL, title, "Map");
let close_hover = point_in(view.mouse[0], view.mouse[1], widget::close_rect(title));
widget::place_close(world, CLOSE_BG, CLOSE_LABEL, title, close_hover);
chart::place(world, &chart_view(view), band(o, s));
}
fn chart_view<'a>(view: &'a MapView<'a>) -> chart::ChartView<'a> {
chart::ChartView {
ids: CHART_IDS,
chart: view.chart,
selected: view.selected,
faulted: None,
pulses: &[],
breakpoints: &[],
pan: view.pan,
mouse: view.mouse,
overflow: OVERFLOW,
}
}
#[cfg(test)]
mod tests {
use concinnity_cook::authoring::registry::RegisteredType;
use concinnity_cook::authoring::world::WorldJsonlAsset;
use concinnity_core::components::{Sprite, TextLabel};
use serde_json::{Value, json};
use super::*;
use crate::editor::behavior::chart::CARD_POOL;
use crate::editor::entry_list::EntryList;
use crate::editor::map::{self, Entries};
use concinnity_core::ecs::asset_id::AssetId;
const O: [f32; 2] = [40.0, 60.0];
fn injected_world() -> World {
ids().test_world()
}
fn asset(ty: RegisteredType, id: &str, args: Value) -> WorldJsonlAsset {
WorldJsonlAsset {
id: id.to_string(),
asset_type: ty,
args,
}
}
fn mapped(assets: Vec<WorldJsonlAsset>) -> Chart {
let mut list = EntryList::default();
map::map(&Entries::new(
assets.into_iter().map(|a| (list.push(Value::Null), a)),
))
}
fn view<'a>(chart: &'a Chart, pan: [f32; 2]) -> MapView<'a> {
MapView {
chart,
selected: None,
pan,
mouse: [-1.0, -1.0],
}
}
fn label(world: &World, id: AssetId) -> TextLabel {
world.get_by_id::<TextLabel>(id).cloned().unwrap()
}
fn sprite(world: &World, id: AssetId) -> Sprite {
world.get_by_id::<Sprite>(id).cloned().unwrap()
}
fn slot_of(world: &World, title: &str) -> Option<usize> {
(0..CARD_POOL).find(|&i| {
let l = label(world, CHART_IDS.card_title(i));
l.visible && l.content == title
})
}
fn drawn(world: &World, title: &str) -> Sprite {
let slot = slot_of(world, title).unwrap_or_else(|| panic!("no `{title}` card drawn"));
sprite(world, CHART_IDS.card_bg(slot))
}
fn imported_scene() -> Vec<WorldJsonlAsset> {
vec![
asset(RegisteredType::Scene, "bistro", json!({})),
asset(
RegisteredType::SceneImport,
"SceneImport#0",
json!({"source": "BistroExterior.fbx", "scene": "bistro"}),
),
]
}
fn scenes(count: usize) -> Vec<WorldJsonlAsset> {
(0..count)
.map(|i| asset(RegisteredType::Scene, &format!("scene_{i}"), json!({})))
.collect()
}
#[test]
fn a_world_of_one_place_draws_a_card_saying_what_is_in_it() {
let chart = mapped(imported_scene());
let mut world = injected_world();
place(&mut world, Some(&view(&chart, [0.0, 0.0])), O, size());
assert!(sprite(&world, PANEL_BG).visible);
assert_eq!(label(&world, TITLE_LABEL).content, "Map");
assert_eq!(slot_of(&world, "bistro"), Some(0));
let detail = label(&world, CHART_IDS.card_detail(0));
assert!(detail.visible);
assert!(detail.content.starts_with("scene, 1 Scene"), "{detail:?}");
}
#[test]
fn a_menu_draws_left_of_the_scene_it_opens() {
let chart = mapped(vec![
asset(RegisteredType::Scene, "bistro", json!({})),
asset(
RegisteredType::MainMenu,
"main",
json!({"initial": true, "items": [{"label": "Start", "action": {"scene": "bistro"}}]}),
),
]);
let mut world = injected_world();
place(&mut world, Some(&view(&chart, [0.0, 0.0])), O, size());
let menu = drawn(&world, "main");
let scene = drawn(&world, "bistro");
assert!(menu.x < scene.x, "{menu:?} is not left of {scene:?}");
assert_eq!(menu.y, scene.y, "one move, one row");
assert!(
(0..CARD_POOL).any(|i| sprite(&world, CHART_IDS.segment(i)).visible),
"no wire between the two",
);
}
#[test]
fn a_place_off_the_canvas_is_drawn_once_it_is_panned_to() {
let chart = mapped(scenes(12));
let far = chart.cards.last().unwrap();
let mut world = injected_world();
place(&mut world, Some(&view(&chart, [0.0, 0.0])), O, size());
assert_eq!(slot_of(&world, &far.title), None, "it starts off the band");
let pan = chart::pan_to(far, canvas(size()), [0.0, 0.0], &chart);
place(&mut world, Some(&view(&chart, pan)), O, size());
assert!(slot_of(&world, &far.title).is_some(), "panning reached it");
}
#[test]
fn a_canvas_of_more_places_than_the_pool_says_how_many_are_missing() {
let chart = mapped(scenes(CARD_POOL + 8));
let mut world = injected_world();
let tall = [size()[0], CHROME_H + chart::height_for(CARD_POOL + 8)];
place(&mut world, Some(&view(&chart, [0.0, 0.0])), O, tall);
let hint = label(&world, CHART_IDS.hint());
assert!(hint.visible);
assert!(hint.content.starts_with("8 more places"), "{hint:?}");
let cards = (0..CARD_POOL)
.filter(|&i| sprite(&world, CHART_IDS.card_bg(i)).visible)
.count();
assert_eq!(cards, CARD_POOL, "every slot went to a place on the canvas");
}
#[test]
fn the_canvas_pans_and_the_rest_of_the_panel_swallows_a_press() {
let chart = mapped(imported_scene());
let v = view(&chart, [0.0, 0.0]);
let s = size();
let b = band(O, s);
assert_eq!(
hit_test(&v, b[0] + b[2] - 10.0, b[1] + b[3] - 10.0, O, s),
Some(MapAction::PanStart)
);
assert_eq!(
hit_test(&v, O[0] + 4.0, O[1] + 2.0, O, s),
Some(MapAction::Consume),
"the title bar is still the panel's"
);
assert_eq!(hit_test(&v, O[0] - 20.0, O[1] - 20.0, O, s), None);
assert_eq!(hit_test(&v, O[0] + s[0] + 20.0, O[1] + 10.0, O, s), None);
}
fn a_world_of_no_places() -> Vec<WorldJsonlAsset> {
vec![
asset(RegisteredType::Material, "mat_floor", json!({})),
asset(RegisteredType::Prop, "floor", json!({"mesh": "floor_mesh"})),
]
}
fn center(chart: &Chart, title: &str, s: [f32; 2], pan: [f32; 2]) -> [f32; 2] {
let card = chart.cards.iter().find(|c| c.title == title).unwrap();
let r = chart::card_rect(card, band(O, s), pan);
[r[0] + r[2] * 0.5, r[1] + r[3] * 0.5]
}
fn index(chart: &Chart, title: &str) -> usize {
chart.cards.iter().position(|c| c.title == title).unwrap()
}
#[test]
fn a_press_on_a_place_selects_it_rather_than_grabbing_the_canvas() {
let chart = mapped(imported_scene());
let s = size();
let v = view(&chart, [0.0, 0.0]);
let at = center(&chart, "bistro", s, [0.0, 0.0]);
assert_eq!(
hit_test(&v, at[0], at[1], O, s),
Some(MapAction::Select(index(&chart, "bistro")))
);
}
#[test]
fn a_press_on_a_card_addressing_nothing_is_swallowed() {
let chart = mapped(a_world_of_no_places());
assert_eq!(chart.cards[0].handle, None, "{:?}", chart.cards[0]);
let s = size();
let v = view(&chart, [0.0, 0.0]);
let at = center(&chart, &chart.cards[0].title.clone(), s, [0.0, 0.0]);
assert_eq!(hit_test(&v, at[0], at[1], O, s), Some(MapAction::Consume));
}
#[test]
fn the_selected_place_is_drawn_lit_up() {
let chart = mapped(vec![
asset(RegisteredType::Scene, "bistro", json!({})),
asset(
RegisteredType::MainMenu,
"main",
json!({"initial": true, "items": [{"label": "Start", "action": {"scene": "bistro"}}]}),
),
]);
let mut world = injected_world();
let mut v = view(&chart, [0.0, 0.0]);
v.selected = Some(index(&chart, "bistro"));
place(&mut world, Some(&v), O, size());
let lit = sprite(
&world,
CHART_IDS.card_bg(slot_of(&world, "bistro").unwrap()),
);
let plain = sprite(&world, CHART_IDS.card_bg(slot_of(&world, "main").unwrap()));
assert!(
lit.border_width > plain.border_width,
"{lit:?} is drawn no differently to {plain:?}",
);
}
#[test]
fn the_root_pan_opens_on_the_place_the_world_starts_in() {
let chart = mapped(imported_scene());
assert_eq!(root_pan(&chart, canvas(size())), [0.0, 0.0]);
}
#[test]
fn the_root_pan_follows_the_card_rather_than_the_canvas_corner() {
let mut chart = mapped(imported_scene());
chart.cards[0].column = 4;
chart.cards[0].row = 6;
chart.columns = 5;
chart.rows = 7;
let pan = root_pan(&chart, canvas(size()));
assert!(
pan[0] > 0.0 && pan[1] > 0.0,
"{pan:?} left it off the canvas"
);
let mut world = injected_world();
place(&mut world, Some(&view(&chart, pan)), O, size());
assert!(
slot_of(&world, "bistro").is_some(),
"the root is not in view"
);
}
}