From 4620469a83b5c04e0b07b3fae84d4d1bb36c0d41 Mon Sep 17 00:00:00 2001
From: Louis Capitanchik <contact@louiscap.co>
Date: Mon, 5 Dec 2022 16:04:17 +0000
Subject: [PATCH] Implement icons & left/right attachment for buttons

---
 .gitlab-ci.yml                            |  83 +----------
 CREDITS.toml                              |  10 +-
 Makefile                                  |   5 +-
 assets/resources.apack                    |   4 +-
 game_core/src/states/menu_state.rs        |   2 +-
 game_core/src/ui/components/button.rs     |  74 +++++++---
 game_core/src/ui/components/inset_icon.rs | 109 +++++++++++++++
 game_core/src/ui/components/mod.rs        |   4 +
 game_core/src/ui/mod.rs                   |   7 +-
 game_core/src/ui/screens/in_game.rs       | 139 +------------------
 game_core/src/ui/utilities.rs             |  15 ++
 game_core/src/ui/widgets/mod.rs           |   3 +
 game_core/src/ui/widgets/town_menu.rs     | 159 ++++++++++++++++++++++
 raw_assets/manifest.toml                  |   6 +
 14 files changed, 384 insertions(+), 236 deletions(-)
 create mode 100644 game_core/src/ui/components/inset_icon.rs
 create mode 100644 game_core/src/ui/widgets/mod.rs
 create mode 100644 game_core/src/ui/widgets/town_menu.rs

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 0dbc1c9..2e61350 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -3,6 +3,7 @@ image: "r.lcr.gr/microhacks/bevy-builder:latest"
 variables:
   BINARY_FOLDER: game_core
   BINARY_NAME: game_core
+  OUTPUT_NAME: TraderTales
 
 stages:
   - build
@@ -52,97 +53,23 @@ build-linux:
   only:
     - trunk
 
-#build-arm64:
-#  image: "r.lcr.gr/microhacks/bevy-builder:arm64"
-#  tags:
-#    - arm64
-#  stage: build
-#  before_script:
-#    - export CARGO_HOME="${CI_PROJECT_DIR}/.cargo"
-#    - export PATH="${CI_PROJECT_DIR}/.cargo/bin:$PATH"
-#  cache:
-#    key: build-cache-arm64
-#    paths:
-#      - .cargo/registry/cache
-#      - .cargo/registry/index
-#      - .cargo/git/db
-#      - .cargo/bin/
-#      - target/
-#  script:
-#    - cargo build --release -p ${BINARY_FOLDER} --target aarch64-unknown-linux-gnu
-#  artifacts:
-#    expire_in: 1 day
-#    paths:
-#      - target/aarch64-unknown-linux-gnu/release/game_core
-#  only:
-#    - trunk
-
-#build-web:
-#  stage: build
-#  before_script:
-#    - export CARGO_HOME="${CI_PROJECT_DIR}/.cargo"
-#    - export PATH="${CI_PROJECT_DIR}/.cargo/bin:$PATH"
-#  cache:
-#    key: build-cache-web
-#    paths:
-#      - .cargo/registry/cache
-#      - .cargo/registry/index
-#      - .cargo/git/db
-#      - .cargo/bin/
-#      - target/
-#  script:
-#    - mkdir -p "${CI_PROJECT_DIR}/${BINARY_FOLDER}/fonts"
-#    - mkdir "${CI_PROJECT_DIR}/${BINARY_FOLDER}/dist"
-#    - make assets
-#    - sed -i 's#public_url = "/"#public_url = "./"#' "${CI_PROJECT_DIR}/${BINARY_FOLDER}/Trunk.toml"
-#    - cd "${CI_PROJECT_DIR}/${BINARY_FOLDER}" && trunk build --release
-#    - cd "${CI_PROJECT_DIR}"
-#  artifacts:
-#    expire_in: 1 day
-#    paths:
-#      - game_core/dist/
-#  only:
-#    - trunk
-
 package-all:
   stage: package
   script:
     - mkdir -p dist/
     - make assets
     - cp -r assets dist/assets
-    - cp target/x86_64-unknown-linux-gnu/release/${BINARY_NAME} "dist/${BINARY_NAME}"
-    - cp target/x86_64-pc-windows-gnu/release/${BINARY_NAME}.exe "dist/${BINARY_NAME}.exe"
-#    - cp target/aarch64-unknown-linux-gnu/release/${BINARY_NAME} "dist/${BINARY_NAME}.arm64"
-    - cd "${CI_PROJECT_DIR}/dist" && zip -r "windows.zip" "./${BINARY_NAME}.exe" ./assets
-    - cd "${CI_PROJECT_DIR}/dist" && zip -r "linux.x86.zip" "./${BINARY_NAME}" ./assets
-#    - cd "${CI_PROJECT_DIR}/dist" && zip -r "linux.arm64.zip" "./${BINARY_NAME}.arm64" ./assets
-#    - cd "${CI_PROJECT_DIR}/${BINARY_FOLDER}/dist" && zip -r "web.zip" ./*
-#    - cd "${CI_PROJECT_DIR}" && mv "${CI_PROJECT_DIR}/game_core/dist/web.zip" "${CI_PROJECT_DIR}/dist/web.zip"
+    - cp target/x86_64-unknown-linux-gnu/release/${BINARY_NAME} "dist/${OUTPUT_NAME}"
+    - cp target/x86_64-pc-windows-gnu/release/${BINARY_NAME}.exe "dist/${OUTPUT_NAME}.exe"
+    - cd "${CI_PROJECT_DIR}/dist" && zip -r "windows.zip" "./${OUTPUT_NAME}.exe" ./assets
+    - cd "${CI_PROJECT_DIR}/dist" && zip -r "linux.x86.zip" "./${OUTPUT_NAME}" ./assets
   dependencies:
     - build-windows
     - build-linux
-#    - build-arm64
-#    - build-web
   artifacts:
     expire_in: 7 days
     paths:
-      - dist/web.zip
       - dist/windows.zip
       - dist/linux.x86.zip
-#      - dist/linux.arm64.zip
   only:
     - trunk
-
-#pages:
-#  stage: package
-#  script:
-#    - mkdir -p public/
-#    - cp -r ${BINARY_FOLDER}/dist/* public/
-#  artifacts:
-#    expire_in: 7 days
-#    paths:
-#      - public
-#  dependencies:
-#    - build-web
-#  only:
-#    - trunk
\ No newline at end of file
diff --git a/CREDITS.toml b/CREDITS.toml
index c43b422..d34acc3 100644
--- a/CREDITS.toml
+++ b/CREDITS.toml
@@ -1,5 +1,11 @@
 [[font]]
-name = "compass_pro.ttf"
+name = "CompassPro.ttf"
 author = "Eeve Somepx"
 website = "https://somepx.itch.io/humble-fonts-free"
-usage = "Main game font"
\ No newline at end of file
+usage = "Header and title font"
+
+[[font]]
+name = "EquipmentPro.ttf"
+author = "Eeve Somepx"
+website = "https://somepx.itch.io/humble-fonts-free"
+usage = "Interface body font"
\ No newline at end of file
diff --git a/Makefile b/Makefile
index d622662..d9f31ce 100644
--- a/Makefile
+++ b/Makefile
@@ -30,11 +30,12 @@ run-web:
 check:
 	cargo check --release --features "bevy/dynamic" -p game_core
 
-pak:
+pak: msdf
 	cd raw_assets && tar -cJf ../assets/resources.apack ./
 
 msdf:
-	msdf-atlas-gen -font raw_assets/fonts/CompassPro.ttf -type msdf -minsize 18 -format png -imageout raw_assets/fonts/CompassPro.png -json raw_assets/fonts/CompassPro.kayak_font
+	@msdf-atlas-gen -font raw_assets/fonts/CompassPro.ttf -type msdf -minsize 32 -format png -imageout raw_assets/fonts/CompassPro.png -json raw_assets/fonts/CompassPro.kayak_font
+	@msdf-atlas-gen -font raw_assets/fonts/EquipmentPro.ttf -type msdf -minsize 32 -format png -imageout raw_assets/fonts/EquipmentPro.png -json raw_assets/fonts/EquipmentPro.kayak_font
 
 build-windows: clean_dist top_tail
 	docker run --rm --name "${PROJECT_NAME}-build-windows" -v "$(CURRENT_DIRECTORY):/app" -w /app --user $(shell id -u):$(shell id -g) r.lcr.gr/microhacks/bevy-builder \
diff --git a/assets/resources.apack b/assets/resources.apack
index b5f38dd..7db455a 100644
--- a/assets/resources.apack
+++ b/assets/resources.apack
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:dcb87c1d8969f1007611545f2b08107140ca49189883bc51fc1ac7d4b3e8d73a
-size 105084
+oid sha256:63fc5d48bdcc9ef1268da7877f399916b45d0463cb3f7a116594220ad5fdfa00
+size 110420
diff --git a/game_core/src/states/menu_state.rs b/game_core/src/states/menu_state.rs
index cf9891c..40ea550 100644
--- a/game_core/src/states/menu_state.rs
+++ b/game_core/src/states/menu_state.rs
@@ -6,7 +6,6 @@ use bevy_tweening::{Animator, EaseFunction, RepeatCount, RepeatStrategy, Tween};
 use iyes_loopless::state::NextState;
 
 use crate::assets::AssetHandles;
-use crate::graphics::AlignedBackground;
 use crate::system::flow::AppState;
 
 #[derive(Component)]
@@ -16,6 +15,7 @@ pub fn spawn_menu_entities(mut commands: Commands, assets: Res<AssetHandles>) {
 	commands.spawn((
 		SpriteBundle {
 			texture: assets.image("menu_background"),
+			transform: Transform::from_scale(Vec3::splat(0.85)),
 			..Default::default()
 		},
 		MenuStateEntity,
diff --git a/game_core/src/ui/components/button.rs b/game_core/src/ui/components/button.rs
index 201720a..58bc3a6 100644
--- a/game_core/src/ui/components/button.rs
+++ b/game_core/src/ui/components/button.rs
@@ -1,16 +1,18 @@
 use bevy::prelude::*;
 use kayak_ui::prelude::*;
 use kayak_ui::widgets::{NinePatch, NinePatchBundle, TextProps, TextWidgetBundle};
-use kayak_ui::DEFAULT_FONT;
 use num_traits::AsPrimitive;
 
 use crate::assets::AssetHandles;
 use crate::parent_widget;
+use crate::ui::components::{IconContent, InsetIconProps, InsetIconWidget};
 use crate::ui::prelude::{edge_px, px, stretch, val_auto, value};
 
 #[derive(Component, Clone, PartialEq, Default)]
 pub struct ButtonWidgetProps {
 	pub text: String,
+	pub left_icon: IconContent,
+	pub right_icon: IconContent,
 	pub font_size: f32,
 	pub is_disabled: bool,
 	pub is_fixed: bool,
@@ -32,6 +34,8 @@ pub fn button_props(text: impl ToString, is_disabled: bool, is_fixed: bool) -> B
 		is_fixed,
 		is_disabled,
 		font_size: 32.0,
+		left_icon: IconContent::None,
+		right_icon: IconContent::None,
 	}
 }
 
@@ -132,20 +136,22 @@ pub fn render_button_widget(
 				}
 			};
 
-			let padding = if state.is_pressed {
-				StyleProp::Value(Edge::new(
-					Units::Pixels(6.0),
-					Units::Stretch(1.0),
-					Units::Pixels(16.0),
-					Units::Stretch(1.0),
-				))
+			let sizing = if state.is_pressed {
+				KStyle {
+					top: px(11.0),
+					right: stretch(1.0),
+					bottom: px(11.0),
+					left: stretch(1.0),
+					..Default::default()
+				}
 			} else {
-				StyleProp::Value(Edge::new(
-					Units::Pixels(3.0),
-					Units::Stretch(1.0),
-					Units::Pixels(19.0),
-					Units::Stretch(1.0),
-				))
+				KStyle {
+					top: px(8.0),
+					right: stretch(1.0),
+					bottom: px(14.0),
+					left: stretch(1.0),
+					..Default::default()
+				}
 			};
 
 			*computed = KStyle {
@@ -160,14 +166,32 @@ pub fn render_button_widget(
 			.into();
 
 			let ninepatch_styles = KStyle {
-				padding,
+				layout_type: value(LayoutType::Row),
+				col_between: px(15.0),
 				..Default::default()
 			};
 
 			let text_style = KStyle {
 				color: value(Color::BLACK),
 				..Default::default()
-			};
+			}
+			.with_style(if state.is_pressed {
+				KStyle {
+					top: px(6.0),
+					right: stretch(1.0),
+					bottom: px(16.0),
+					left: stretch(1.0),
+					..Default::default()
+				}
+			} else {
+				KStyle {
+					top: px(3.0),
+					right: stretch(1.0),
+					bottom: px(19.0),
+					left: stretch(1.0),
+					..Default::default()
+				}
+			});
 
 			rsx! {
 				<NinePatchBundle
@@ -175,6 +199,15 @@ pub fn render_button_widget(
 					nine_patch={nine_vals}
 					styles={ninepatch_styles}
 				>
+					{ if !props.left_icon.is_none() {
+						constructor!(
+							<InsetIconWidget
+								styles={sizing.clone()}
+
+								props={InsetIconProps { image: props.left_icon.clone(), size: props.font_size }}
+							/>
+						);
+					}}
 					<TextWidgetBundle
 						text={TextProps {
 							content: props.text.clone(),
@@ -186,6 +219,15 @@ pub fn render_button_widget(
 						}}
 						styles={text_style}
 					/>
+
+					{ if !props.right_icon.is_none() {
+						constructor!(
+							<InsetIconWidget
+								styles={sizing.clone()}
+								props={InsetIconProps { image: props.right_icon.clone(), size: props.font_size }}
+							/>
+						);
+					}}
 				</NinePatchBundle>
 			}
 		}
diff --git a/game_core/src/ui/components/inset_icon.rs b/game_core/src/ui/components/inset_icon.rs
new file mode 100644
index 0000000..ac4b68c
--- /dev/null
+++ b/game_core/src/ui/components/inset_icon.rs
@@ -0,0 +1,109 @@
+use bevy::prelude::*;
+use kayak_ui::prelude::*;
+use kayak_ui::widgets::{KImage, KImageBundle, TextureAtlasBundle, TextureAtlasProps};
+
+use crate::assets::AssetHandles;
+use crate::parent_widget;
+use crate::ui::prelude::*;
+
+#[derive(Clone, Default, Eq, PartialEq)]
+pub enum IconContent {
+	Image(String),
+	Atlas(String, usize),
+	#[default]
+	None,
+}
+
+impl IconContent {
+	pub fn is_none(&self) -> bool {
+		match self {
+			Self::None => true,
+			_ => false,
+		}
+	}
+}
+
+#[derive(Clone, Component, PartialEq)]
+pub struct InsetIconProps {
+	pub image: IconContent,
+	pub size: f32,
+}
+
+impl Default for InsetIconProps {
+	fn default() -> Self {
+		Self {
+			image: IconContent::None,
+			size: 48.0,
+		}
+	}
+}
+
+impl Widget for InsetIconProps {}
+
+parent_widget!(InsetIconProps => InsetIconWidget);
+
+pub fn render_inset_icon_widget(
+	In((mut widget_context, entity)): In<(KayakWidgetContext, Entity)>,
+	mut commands: Commands,
+	mut query: Query<(&InsetIconProps, &mut ComputedStyles, &KStyle)>,
+	assets: Res<AssetHandles>,
+	atlass: Res<Assets<TextureAtlas>>,
+) -> bool {
+	let parent_id = Some(entity);
+
+	if let Ok((props, mut computed, style)) = query.get_mut(entity) {
+		*computed = KStyle {
+			render_command: value(RenderCommand::Layout),
+			min_height: px(32.0),
+			min_width: px(32.0),
+			height: px(props.size),
+			width: px(props.size),
+			padding: value(Edge::all(Units::Stretch(0.0))),
+			..Default::default()
+		}
+		.with_style(style)
+		.into();
+
+		let image_styles = KStyle {
+			width: stretch(1.0),
+			height: stretch(1.0),
+			..Default::default()
+		};
+
+		match &props.image {
+			IconContent::Image(name) => {
+				let handle = assets.image(name);
+
+				rsx! {
+					<KImageBundle
+						image={KImage(handle)}
+						styles={image_styles}
+					/>
+				}
+			}
+			IconContent::Atlas(name, index) => {
+				let atlas_handle = assets.atlas(name);
+				let image_handle = assets.image(name);
+				if let Some(atlas) = atlass.get(&atlas_handle) {
+					let rect = atlas.textures[*index];
+					let position = rect.min;
+					let tile_size = rect.max - rect.min;
+
+					rsx! {
+						<TextureAtlasBundle
+							atlas={TextureAtlasProps {
+								tile_size,
+								position,
+								handle: image_handle,
+							}}
+							styles={image_styles}
+						/>
+					}
+				}
+			}
+			IconContent::None => {}
+		}
+	}
+
+	true
+}
diff --git a/game_core/src/ui/components/mod.rs b/game_core/src/ui/components/mod.rs
index fe7558c..7a1147c 100644
--- a/game_core/src/ui/components/mod.rs
+++ b/game_core/src/ui/components/mod.rs
@@ -4,6 +4,7 @@ mod a_text_box;
 mod button;
 mod debug_info;
 mod image_button;
+mod inset_icon;
 mod panel;
 mod v_divider;
 
@@ -17,5 +18,8 @@ pub use self::debug_info::{render_debug_info, DebugInfoProps, DebugInfoWidget};
 pub use self::image_button::{
 	render_image_button_widget, ImageButtonWidget, ImageButtonWidgetProps, ImageButtonWidgetState,
 };
+pub use self::inset_icon::{
+	render_inset_icon_widget, IconContent, InsetIconProps, InsetIconWidget,
+};
 pub use self::panel::{render_panel_widget, PanelProps, PanelVariant, PanelWidget};
 pub use self::v_divider::{render_v_divider, VDividerWidget, VDividerWidgetProps};
diff --git a/game_core/src/ui/mod.rs b/game_core/src/ui/mod.rs
index d815171..8d2581b 100644
--- a/game_core/src/ui/mod.rs
+++ b/game_core/src/ui/mod.rs
@@ -5,9 +5,9 @@ use kayak_ui::widgets::KayakWidgets;
 // pub mod clrs;
 pub mod components;
 pub mod screens;
-pub mod utilities;
-// pub mod widgets;
 pub mod sync;
+pub mod utilities;
+pub mod widgets;
 
 pub mod prelude {
 	use bevy::prelude::FromReflect;
@@ -78,7 +78,8 @@ mod _config {
 		mut font_mapping: ResMut<FontMapping>,
 	) {
 		log::info!("Configuring Fonts And UI");
-		font_mapping.set_default(assets.kayak_font("compass_pro"));
+		font_mapping.set_default(assets.kayak_font("equipment_pro"));
+		font_mapping.add("header", assets.kayak_font("compass_pro"));
 	}
 
 	pub fn remove_ui(mut commands: Commands, query: Query<Entity, With<StateUIRoot>>) {
diff --git a/game_core/src/ui/screens/in_game.rs b/game_core/src/ui/screens/in_game.rs
index 3638d0e..a24c440 100644
--- a/game_core/src/ui/screens/in_game.rs
+++ b/game_core/src/ui/screens/in_game.rs
@@ -2,66 +2,20 @@ use bevy::prelude::*;
 use kayak_ui::prelude::*;
 use kayak_ui::widgets::{ElementBundle, KayakAppBundle, TextProps, TextWidgetBundle};
 
-use crate::assets::AssetHandles;
-use crate::states::Player;
-use crate::ui::components::*;
-use crate::ui::prelude::{edge_px, pct, px, stretch, value};
+use crate::ui::prelude::{px, stretch, value};
 use crate::ui::sync::UITravelInfo;
 use crate::ui::utilities::context::create_root_context;
-use crate::ui::utilities::{widget_update_with_resource, StateUIRoot};
-use crate::world::{CurrentResidence, MapQuery, TownPaths};
-use crate::{
-	empty_props, on_button_click, parent_widget, register_widget_with_resource,
-	register_widget_with_update,
-};
+use crate::ui::utilities::StateUIRoot;
+use crate::ui::widgets::*;
+use crate::{empty_props, parent_widget, register_widget_with_resource};
 
 empty_props!(InGameProps);
 parent_widget!(InGameProps => InGameLayout);
 
-pub fn transit_button_factory(target: String) -> OnEvent {
-	let target = target.clone();
-	on_button_click!(
-		ParamSet<(
-			Commands,
-			Res<TownPaths>,
-			Query<(Entity, &CurrentResidence), With<Player>>,
-			MapQuery,
-		)>,
-		|mut params: ParamSet<(
-			Commands,
-			Res<TownPaths>,
-			Query<(Entity, &CurrentResidence), With<Player>>,
-			MapQuery,
-		)>| {
-			let target = target.clone();
-			let (entity, current) = {
-				match params.p2().get_single() {
-					Ok((entity, current)) => (entity.clone(), (current.get_location()).clone()),
-					_ => return,
-				}
-			};
-
-			let places = match params.p1().routes.get(&current) {
-				Some(places) => places.clone(),
-				None => return,
-			};
-
-			let bundle = match params.p3().get_active_level() {
-				Some(level) => places.create_route_bundle_for(target, level).unwrap(),
-				None => return,
-			};
-
-			params.p0().entity(entity).insert(bundle);
-		}
-	)
-}
-
 pub fn render_game_panels(
 	In((widget_context, entity)): In<(KayakWidgetContext, Entity)>,
 	mut commands: Commands,
 	ui_data: Res<UITravelInfo>,
-	assets: Res<AssetHandles>,
-	places: Res<TownPaths>,
 ) -> bool {
 	let parent_id = Some(entity);
 
@@ -74,29 +28,8 @@ pub fn render_game_panels(
 		..Default::default()
 	};
 
-	let panel_style = KStyle {
-		position_type: value(KPositionType::SelfDirected),
-		width: pct(65.0),
-		height: pct(80.0),
-		min_width: px(400.0),
-		min_height: px(300.0),
-		max_width: px(850.0),
-		max_height: px(550.0),
-		top: stretch(1.0),
-		left: stretch(1.0),
-		right: stretch(1.0),
-		bottom: stretch(1.0),
-		layout_type: value(LayoutType::Column),
-		row_between: px(20.0),
-		..Default::default()
-	};
-
 	rsx! {
-		<ElementBundle
-			styles={KStyle {
-				..Default::default()
-			}}
-		>
+		<ElementBundle>
 			{ if ui_data.distance_remaining > 0.1 {
 				constructor! {
 					<TextWidgetBundle
@@ -111,66 +44,8 @@ pub fn render_game_panels(
 			}}
 
 			{if ui_data.is_in_town {
-				if let Some(ref place) = ui_data.current_town {
-					constructor! {
-						<PanelWidget
-							styles={panel_style}
-						>
-							<TextWidgetBundle
-								text={TextProps {
-									content: format!("Hark! You enter {}", &place),
-									size: 48.0,
-									..Default::default()
-								}}
-								styles={KStyle {
-									color: value(Color::BLACK),
-									padding: edge_px(20.0),
-									left: stretch(1.0),
-									right: stretch(1.0),
-									..Default::default()
-								}}
-							/>
-
-							<VDividerWidget props={VDividerWidgetProps { height: 4.0, padding: 5.0, color: Color::rgb(0.52, 0.369, 0.18)}} />
-
-							<TextWidgetBundle
-								text={TextProps {
-									content: format!("Set off for:"),
-									size: 32.0,
-									..Default::default()
-								}}
-								styles={KStyle {
-									color: value(Color::BLACK),
-									padding: edge_px(20.0),
-									left: stretch(1.0),
-									right: stretch(1.0),
-									..Default::default()
-								}}
-							/>
-
-							{
-								for (place, distance) in ui_data.travel_options.iter() {
-									constructor! {
-										<ButtonWidget
-											styles={
-												KStyle {
-													left: stretch(1.0),
-													right: stretch(1.0),
-													width: pct(70.0),
-													min_width: px(300.0),
-													max_width: px(600.0),
-													bottom: px(10.0),
-													..Default::default()
-												}
-											}
-											props={ButtonWidgetProps::text(format!("{}: {:.2}KM", &place, distance), 28.0)}
-											on_event={transit_button_factory(place.clone())}
-										/>
-									}
-								}
-							}
-						</PanelWidget>
-					}
+				constructor! {
+					<TownMenuPanel />
 				}
 			}}
 		</ElementBundle>
diff --git a/game_core/src/ui/utilities.rs b/game_core/src/ui/utilities.rs
index 54c2ca8..5dfb327 100644
--- a/game_core/src/ui/utilities.rs
+++ b/game_core/src/ui/utilities.rs
@@ -138,6 +138,8 @@ pub mod context {
 
 	use crate::register_widget;
 	use crate::ui::components::*;
+	use crate::ui::sync::UITravelInfo;
+	use crate::ui::widgets::*;
 
 	pub fn create_root_context() -> KayakRootContext {
 		let mut widget_context = KayakRootContext::new();
@@ -180,6 +182,19 @@ pub mod context {
 				ATextBoxState,
 				render_text_box_widget
 			);
+			register_widget!(
+				widget_context,
+				InsetIconProps,
+				EmptyState,
+				render_inset_icon_widget
+			);
+			register_widget_with_resource!(
+				widget_context,
+				TownMenuPanelProps,
+				EmptyState,
+				UITravelInfo,
+				render_town_menu_panel
+			);
 		}
 	}
 }
diff --git a/game_core/src/ui/widgets/mod.rs b/game_core/src/ui/widgets/mod.rs
new file mode 100644
index 0000000..7acc2b1
--- /dev/null
+++ b/game_core/src/ui/widgets/mod.rs
@@ -0,0 +1,3 @@
+mod town_menu;
+
+pub use town_menu::{render_town_menu_panel, TownMenuPanel, TownMenuPanelProps};
diff --git a/game_core/src/ui/widgets/town_menu.rs b/game_core/src/ui/widgets/town_menu.rs
new file mode 100644
index 0000000..b7e75c1
--- /dev/null
+++ b/game_core/src/ui/widgets/town_menu.rs
@@ -0,0 +1,159 @@
+use bevy::prelude::*;
+use kayak_ui::prelude::*;
+use kayak_ui::widgets::{
+	ElementBundle, ScrollBoxBundle, ScrollBoxProps, ScrollContextProviderBundle, TextProps,
+	TextWidgetBundle,
+};
+
+use crate::assets::AssetHandles;
+use crate::states::Player;
+use crate::ui::components::*;
+use crate::ui::prelude::*;
+use crate::ui::sync::UITravelInfo;
+use crate::ui::widgets::*;
+use crate::world::{CurrentResidence, MapQuery, TownPaths};
+use crate::{basic_widget, empty_props, on_button_click};
+
+pub fn transit_button_factory(target: String) -> OnEvent {
+	let target = target.clone();
+	on_button_click!(
+		ParamSet<(
+			Commands,
+			Res<TownPaths>,
+			Query<(Entity, &CurrentResidence), With<Player>>,
+			MapQuery,
+		)>,
+		|mut params: ParamSet<(
+			Commands,
+			Res<TownPaths>,
+			Query<(Entity, &CurrentResidence), With<Player>>,
+			MapQuery,
+		)>| {
+			let target = target.clone();
+			let (entity, current) = {
+				match params.p2().get_single() {
+					Ok((entity, current)) => (entity.clone(), (current.get_location()).clone()),
+					_ => return,
+				}
+			};
+
+			let places = match params.p1().routes.get(&current) {
+				Some(places) => places.clone(),
+				None => return,
+			};
+
+			let bundle = match params.p3().get_active_level() {
+				Some(level) => places.create_route_bundle_for(target, level).unwrap(),
+				None => return,
+			};
+
+			params.p0().entity(entity).insert(bundle);
+		}
+	)
+}
+
+empty_props!(TownMenuPanelProps);
+basic_widget!(TownMenuPanelProps => TownMenuPanel);
+
+pub fn render_town_menu_panel(
+	In((widget_context, entity)): In<(KayakWidgetContext, Entity)>,
+	mut commands: Commands,
+	ui_data: Res<UITravelInfo>,
+	places: Res<TownPaths>,
+) -> bool {
+	let parent_id = Some(entity);
+
+	let distance_style = KStyle {
+		position_type: value(KPositionType::SelfDirected),
+		top: stretch(1.0),
+		left: stretch(1.0),
+		right: stretch(1.0),
+		bottom: px(50.0),
+		..Default::default()
+	};
+
+	let panel_style = KStyle {
+		position_type: value(KPositionType::SelfDirected),
+		width: pct(65.0),
+		height: pct(80.0),
+		min_width: px(400.0),
+		min_height: px(300.0),
+		max_width: px(850.0),
+		max_height: px(550.0),
+		top: stretch(1.0),
+		left: stretch(1.0),
+		right: stretch(1.0),
+		bottom: stretch(1.0),
+		layout_type: value(LayoutType::Column),
+		row_between: px(20.0),
+		..Default::default()
+	};
+
+	if let Some(ref place) = ui_data.current_town {
+		rsx! {
+			<PanelWidget
+				styles={panel_style}
+			>
+				<TextWidgetBundle
+					text={TextProps {
+						font: Some(String::from("header")),
+						content: format!("Hark! You enter {}", &place),
+						size: 48.0,
+						..Default::default()
+					}}
+					styles={KStyle {
+						color: value(Color::BLACK),
+						padding: edge_px(20.0),
+						left: stretch(1.0),
+						right: stretch(1.0),
+						..Default::default()
+					}}
+				/>
+
+				<VDividerWidget props={VDividerWidgetProps { height: 4.0, padding: 5.0, color: Color::rgb(0.52, 0.369, 0.18)}} />
+
+				<TextWidgetBundle
+					text={TextProps {
+						content: format!("Set off for:"),
+						size: 32.0,
+						..Default::default()
+					}}
+					styles={KStyle {
+						color: value(Color::BLACK),
+						padding: edge_px(20.0),
+						left: stretch(1.0),
+						right: stretch(1.0),
+						..Default::default()
+					}}
+				/>
+
+				{
+					for (place, distance) in ui_data.travel_options.iter() {
+						constructor! {
+							<ButtonWidget
+								styles={
+									KStyle {
+										left: stretch(1.0),
+										right: stretch(1.0),
+										width: pct(70.0),
+										min_width: px(300.0),
+										max_width: px(600.0),
+										bottom: px(10.0),
+										..Default::default()
+									}
+								}
+								props={ButtonWidgetProps {
+									left_icon: IconContent::Atlas(String::from("characters"), 0),
+									..ButtonWidgetProps::text(format!("{}: {:.2}KM", &place, distance), 28.0)
+								}}
+								on_event={transit_button_factory(place.clone())}
+							/>
+						}
+					}
+				}
+			</PanelWidget>
+		}
+	}
+
+	true
+}
diff --git a/raw_assets/manifest.toml b/raw_assets/manifest.toml
index 0bd6b56..c2b8e6e 100644
--- a/raw_assets/manifest.toml
+++ b/raw_assets/manifest.toml
@@ -49,6 +49,12 @@ ttf = "fonts/CompassPro.ttf"
 image = "fonts/CompassPro.png"
 msdf = "fonts/CompassPro.kayak_font"
 
+[[fonts]]
+name = "equipment_pro"
+ttf = "fonts/EquipmentPro.ttf"
+image = "fonts/EquipmentPro.png"
+msdf = "fonts/EquipmentPro.kayak_font"
+
 [[ldtk]]
 path = "ldtk/overworld_maps.ldtk"
 name = "overworld"
\ No newline at end of file
-- 
GitLab