mirror of
https://github.com/griffi-gh/kubi.git
synced 2024-12-25 21:28:20 -06:00
Add touch controls, switch to tinymap for keyboard input
This commit is contained in:
parent
48497c6e17
commit
bbea1659e1
BIN
.readme/touch_controls.png
Normal file
BIN
.readme/touch_controls.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
10
Cargo.lock
generated
10
Cargo.lock
generated
|
@ -976,6 +976,7 @@ dependencies = [
|
||||||
"shipyard",
|
"shipyard",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"strum",
|
"strum",
|
||||||
|
"tinyset",
|
||||||
"uflow",
|
"uflow",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
@ -1989,6 +1990,15 @@ dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyset"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2417e47ddd3809ad40222777ac754ee881b3a6401e38cbeeeb3ee1ca5f30aa0"
|
||||||
|
dependencies = [
|
||||||
|
"rand",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
|
|
39
README.md
39
README.md
|
@ -26,49 +26,64 @@
|
||||||
<h2>download</h2>
|
<h2>download</h2>
|
||||||
<a href="https://github.com/griffi-gh/kubi/releases/tag/nightly">Latest nightly release</a>
|
<a href="https://github.com/griffi-gh/kubi/releases/tag/nightly">Latest nightly release</a>
|
||||||
|
|
||||||
<h2>building</h2>
|
<h2>build for windows/linux</h2>
|
||||||
|
|
||||||
build/run
|
**build/run**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --bin kubi
|
cargo build --bin kubi
|
||||||
cargo run --bin kubi
|
cargo run --bin kubi
|
||||||
```
|
```
|
||||||
|
|
||||||
build in release mode, with nightly optimizations
|
**build in release mode, with nightly optimizations**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo +nightly build --bin kubi --features nightly --release
|
cargo +nightly build --bin kubi --features nightly --release
|
||||||
```
|
```
|
||||||
|
|
||||||
build for android
|
<h2>build for android</h2>
|
||||||
|
|
||||||
please note that android support is purely experimental!
|
please note that android support is highly experimental!\
|
||||||
gamepad, keyboard and mouse input is currently borked, and touch controls are not available.
|
gamepad, mouse input is currently borked, and proper touch controls are not available.\
|
||||||
srgb and blending are broken too, which leads to many rendering issues
|
srgb and blending are broken too, which leads to many rendering issues
|
||||||
|
|
||||||
prerequisites: Android SDK, NDK, platform-tools, latest JDK (all should be in $PATH)
|
prerequisites: Android SDK, command line tools, NDK, platform-tools, latest JDK\
|
||||||
|
(make sure that your $PATH variable is configured properly)
|
||||||
|
|
||||||
Setup:
|
**Setup:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo install cargo-apk
|
cargo install cargo-apk
|
||||||
cargo target add aarch64-linux-android
|
cargo target add aarch64-linux-android
|
||||||
```
|
```
|
||||||
|
|
||||||
Build:
|
**Build:**
|
||||||
`--no-default-features` is required for keyboard input!
|
|
||||||
|
`--no-default-features` is required for keyboard input!\
|
||||||
|
(`prefer-raw-events` feature *must* be disabled on android)
|
||||||
|
|
||||||
|
Mouse input is not implemented, touch only!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo apk build -p kubi --no-default-features
|
cargo apk build -p kubi --no-default-features
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
**Run:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo apk run -p kubi --features nightly
|
cargo apk run -p kubi --no-default-features
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<h2>android controls</h2>
|
||||||
|
|
||||||
|
<img src=".readme/touch_controls.png" alt="touch control scheme" width="300">
|
||||||
|
|
||||||
|
- Left side: **Movement**
|
||||||
|
- Rigth side: **Camera controls**
|
||||||
|
- Bottom right corner:
|
||||||
|
- **B** (e.g. place blocks)
|
||||||
|
- **A** (e.g. break, attack)
|
||||||
|
|
||||||
<h2>mutiplayer</h2>
|
<h2>mutiplayer</h2>
|
||||||
|
|
||||||
to join a multiplayer server, just pass the ip address as an argument
|
to join a multiplayer server, just pass the ip address as an argument
|
||||||
|
|
|
@ -27,6 +27,7 @@ postcard = { version = "1.0", features = ["alloc"] }
|
||||||
serde_json = { version = "1.0", optional = true }
|
serde_json = { version = "1.0", optional = true }
|
||||||
lz4_flex = { version = "0.10", default-features = false, features = ["std", "checked-decode"] }
|
lz4_flex = { version = "0.10", default-features = false, features = ["std", "checked-decode"] }
|
||||||
static_assertions = "1.1"
|
static_assertions = "1.1"
|
||||||
|
tinyset = "0.4"
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
ndk = "0.7"
|
ndk = "0.7"
|
||||||
|
|
|
@ -30,7 +30,7 @@ fn pick_block_with_number_keys(
|
||||||
) {
|
) {
|
||||||
let Some((_, mut holding)) = (&main_player, &mut holding).iter().next() else { return };
|
let Some((_, mut holding)) = (&main_player, &mut holding).iter().next() else { return };
|
||||||
for &(key, block) in BLOCK_KEY_MAP {
|
for &(key, block) in BLOCK_KEY_MAP {
|
||||||
if input.keyboard_state.contains(&key) {
|
if input.keyboard_state.contains(key as u32) {
|
||||||
holding.0 = Some(block);
|
holding.0 = Some(block);
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ pub fn exit_on_esc(
|
||||||
raw_inputs: UniqueView<RawKbmInputState>,
|
raw_inputs: UniqueView<RawKbmInputState>,
|
||||||
mut control_flow: UniqueViewMut<SetControlFlow>
|
mut control_flow: UniqueViewMut<SetControlFlow>
|
||||||
) {
|
) {
|
||||||
if raw_inputs.keyboard_state.contains(&VirtualKeyCode::Escape) {
|
if raw_inputs.keyboard_state.contains(VirtualKeyCode::Escape as u32) {
|
||||||
control_flow.0 = Some(ControlFlow::Exit);
|
control_flow.0 = Some(ControlFlow::Exit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
use glam::UVec2;
|
use glam::UVec2;
|
||||||
use shipyard::{World, Component, AllStoragesViewMut, SparseSet, NonSendSync, UniqueView};
|
use shipyard::{World, Component, AllStoragesViewMut, SparseSet, NonSendSync, UniqueView};
|
||||||
use glium::glutin::event::{Event, DeviceEvent, DeviceId, WindowEvent};
|
use glium::glutin::event::{Event, DeviceEvent, DeviceId, WindowEvent, Touch};
|
||||||
use crate::rendering::Renderer;
|
use crate::rendering::Renderer;
|
||||||
|
|
||||||
pub mod player_actions;
|
pub mod player_actions;
|
||||||
|
@ -17,6 +17,10 @@ pub struct InputDeviceEvent{
|
||||||
pub event: DeviceEvent
|
pub event: DeviceEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Component, Clone, Copy, Debug)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct TouchEvent(pub Touch);
|
||||||
|
|
||||||
#[derive(Component, Clone, Copy, Debug, Default)]
|
#[derive(Component, Clone, Copy, Debug, Default)]
|
||||||
pub struct WindowResizedEvent(pub UVec2);
|
pub struct WindowResizedEvent(pub UVec2);
|
||||||
|
|
||||||
|
@ -24,7 +28,6 @@ pub fn process_glutin_events(world: &mut World, event: &Event<'_, ()>) {
|
||||||
#[allow(clippy::collapsible_match, clippy::single_match)]
|
#[allow(clippy::collapsible_match, clippy::single_match)]
|
||||||
match event {
|
match event {
|
||||||
Event::WindowEvent { window_id: _, event } => match event {
|
Event::WindowEvent { window_id: _, event } => match event {
|
||||||
|
|
||||||
WindowEvent::Resized(size) => {
|
WindowEvent::Resized(size) => {
|
||||||
world.add_entity((
|
world.add_entity((
|
||||||
EventComponent,
|
EventComponent,
|
||||||
|
@ -43,6 +46,13 @@ pub fn process_glutin_events(world: &mut World, event: &Event<'_, ()>) {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WindowEvent::Touch(touch) => {
|
||||||
|
world.add_entity((
|
||||||
|
EventComponent,
|
||||||
|
TouchEvent(*touch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
_ => ()
|
_ => ()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
use gilrs::{Gilrs, GamepadId, Button, Event, Axis};
|
use gilrs::{Gilrs, GamepadId, Button, Event, Axis};
|
||||||
use glam::{Vec2, DVec2, vec2};
|
use glam::{Vec2, DVec2, vec2, dvec2};
|
||||||
use glium::glutin::event::{DeviceEvent, VirtualKeyCode, ElementState};
|
use glium::glutin::event::{DeviceEvent, DeviceId, VirtualKeyCode, ElementState, TouchPhase};
|
||||||
use hashbrown::HashSet;
|
use hashbrown::HashMap;
|
||||||
|
use tinyset::{SetU32, SetU64};
|
||||||
use nohash_hasher::BuildNoHashHasher;
|
use nohash_hasher::BuildNoHashHasher;
|
||||||
use shipyard::{AllStoragesView, Unique, View, IntoIter, UniqueViewMut, Workload, IntoWorkload, UniqueView, NonSendSync};
|
use shipyard::{AllStoragesView, Unique, View, IntoIter, UniqueViewMut, Workload, IntoWorkload, UniqueView, NonSendSync};
|
||||||
use crate::events::InputDeviceEvent;
|
use crate::{
|
||||||
|
events::{InputDeviceEvent, TouchEvent},
|
||||||
|
rendering::WindowSize
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Unique, Clone, Copy, Default, Debug)]
|
#[derive(Unique, Clone, Copy, Default, Debug)]
|
||||||
pub struct Inputs {
|
pub struct Inputs {
|
||||||
|
@ -19,11 +23,62 @@ pub struct PrevInputs(pub Inputs);
|
||||||
|
|
||||||
#[derive(Unique, Clone, Default, Debug)]
|
#[derive(Unique, Clone, Default, Debug)]
|
||||||
pub struct RawKbmInputState {
|
pub struct RawKbmInputState {
|
||||||
pub keyboard_state: HashSet<VirtualKeyCode, BuildNoHashHasher<u32>>,
|
pub keyboard_state: SetU32,
|
||||||
pub button_state: [bool; 32],
|
pub button_state: [bool; 32],
|
||||||
pub mouse_delta: DVec2
|
pub mouse_delta: DVec2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub enum FingerCheck {
|
||||||
|
#[default]
|
||||||
|
Start,
|
||||||
|
Current,
|
||||||
|
StartOrCurrent,
|
||||||
|
StartAndCurrent,
|
||||||
|
NotMoved,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Finger {
|
||||||
|
pub id: u64,
|
||||||
|
pub device_id: DeviceId,
|
||||||
|
pub prev_position: DVec2,
|
||||||
|
pub start_position: DVec2,
|
||||||
|
pub current_position: DVec2,
|
||||||
|
pub has_moved: bool,
|
||||||
|
}
|
||||||
|
impl Finger {
|
||||||
|
pub fn within_area(&self, area_pos: DVec2, area_size: DVec2, check: FingerCheck) -> bool {
|
||||||
|
let within_area = |pos: DVec2| -> bool {
|
||||||
|
((pos - area_pos).min_element() >= 0.) &&
|
||||||
|
((pos - (area_pos + area_size)).max_element() <= 0.)
|
||||||
|
};
|
||||||
|
let start = within_area(self.start_position);
|
||||||
|
let current = within_area(self.current_position);
|
||||||
|
match check {
|
||||||
|
FingerCheck::Start => start,
|
||||||
|
FingerCheck::Current => current,
|
||||||
|
FingerCheck::StartOrCurrent => start || current,
|
||||||
|
FingerCheck::StartAndCurrent => start && current,
|
||||||
|
FingerCheck::NotMoved => current && !self.has_moved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Unique, Clone, Default, Debug)]
|
||||||
|
pub struct RawTouchState {
|
||||||
|
//TODO: handle multiple touch devices somehow
|
||||||
|
pub fingers: HashMap<u64, Finger, BuildNoHashHasher<u64>>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTouchState {
|
||||||
|
pub fn query_area(&self, area_pos: DVec2, area_size: DVec2, check: FingerCheck) -> impl Iterator<Item = Finger> + '_ {
|
||||||
|
self.fingers.iter().filter_map(move |(_, &finger)| {
|
||||||
|
finger.within_area(area_pos, area_size, check).then_some(finger)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Unique)]
|
#[derive(Unique)]
|
||||||
pub struct GilrsWrapper(Option<Gilrs>);
|
pub struct GilrsWrapper(Option<Gilrs>);
|
||||||
|
|
||||||
|
@ -46,8 +101,8 @@ fn process_events(
|
||||||
DeviceEvent::Key(input) => {
|
DeviceEvent::Key(input) => {
|
||||||
if let Some(keycode) = input.virtual_keycode {
|
if let Some(keycode) = input.virtual_keycode {
|
||||||
match input.state {
|
match input.state {
|
||||||
ElementState::Pressed => input_state.keyboard_state.insert(keycode),
|
ElementState::Pressed => input_state.keyboard_state.insert(keycode as u32),
|
||||||
ElementState::Released => input_state.keyboard_state.remove(&keycode),
|
ElementState::Released => input_state.keyboard_state.remove(keycode as u32),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -61,6 +116,39 @@ fn process_events(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn process_touch_events(
|
||||||
|
touch_events: View<TouchEvent>,
|
||||||
|
mut touch_state: UniqueViewMut<RawTouchState>,
|
||||||
|
) {
|
||||||
|
for (_, finger) in &mut touch_state.fingers {
|
||||||
|
finger.prev_position = finger.current_position;
|
||||||
|
}
|
||||||
|
for event in touch_events.iter() {
|
||||||
|
let position = dvec2(event.0.location.x, event.0.location.y);
|
||||||
|
match event.0.phase {
|
||||||
|
TouchPhase::Started => {
|
||||||
|
touch_state.fingers.insert(event.0.id, Finger {
|
||||||
|
id: event.0.id,
|
||||||
|
device_id: event.0.device_id,
|
||||||
|
start_position: position,
|
||||||
|
current_position: position,
|
||||||
|
prev_position: position,
|
||||||
|
has_moved: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
TouchPhase::Moved => {
|
||||||
|
if let Some(finger) = touch_state.fingers.get_mut(&event.0.id) {
|
||||||
|
finger.has_moved = true;
|
||||||
|
finger.current_position = position;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TouchPhase::Ended | TouchPhase::Cancelled => {
|
||||||
|
touch_state.fingers.remove(&event.0.id);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn process_gilrs_events(
|
fn process_gilrs_events(
|
||||||
mut gilrs: NonSendSync<UniqueViewMut<GilrsWrapper>>,
|
mut gilrs: NonSendSync<UniqueViewMut<GilrsWrapper>>,
|
||||||
mut active_gamepad: UniqueViewMut<ActiveGamepad>
|
mut active_gamepad: UniqueViewMut<ActiveGamepad>
|
||||||
|
@ -85,10 +173,10 @@ fn update_input_state (
|
||||||
mut inputs: UniqueViewMut<Inputs>,
|
mut inputs: UniqueViewMut<Inputs>,
|
||||||
) {
|
) {
|
||||||
inputs.movement += Vec2::new(
|
inputs.movement += Vec2::new(
|
||||||
raw_inputs.keyboard_state.contains(&VirtualKeyCode::D) as u32 as f32 -
|
raw_inputs.keyboard_state.contains(VirtualKeyCode::D as u32) as u32 as f32 -
|
||||||
raw_inputs.keyboard_state.contains(&VirtualKeyCode::A) as u32 as f32,
|
raw_inputs.keyboard_state.contains(VirtualKeyCode::A as u32) as u32 as f32,
|
||||||
raw_inputs.keyboard_state.contains(&VirtualKeyCode::W) as u32 as f32 -
|
raw_inputs.keyboard_state.contains(VirtualKeyCode::W as u32) as u32 as f32 -
|
||||||
raw_inputs.keyboard_state.contains(&VirtualKeyCode::S) as u32 as f32
|
raw_inputs.keyboard_state.contains(VirtualKeyCode::S as u32) as u32 as f32
|
||||||
);
|
);
|
||||||
inputs.look += raw_inputs.mouse_delta.as_vec2();
|
inputs.look += raw_inputs.mouse_delta.as_vec2();
|
||||||
inputs.action_a |= raw_inputs.button_state[1];
|
inputs.action_a |= raw_inputs.button_state[1];
|
||||||
|
@ -112,6 +200,60 @@ fn update_input_state_gamepad (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_input_state_touch (
|
||||||
|
touch_state: UniqueView<RawTouchState>,
|
||||||
|
win_size: UniqueView<WindowSize>,
|
||||||
|
mut inputs: UniqueViewMut<Inputs>,
|
||||||
|
) {
|
||||||
|
let w = win_size.0.as_dvec2();
|
||||||
|
|
||||||
|
//Movement
|
||||||
|
if let Some(finger) = touch_state.query_area(
|
||||||
|
dvec2(0., 0.),
|
||||||
|
dvec2(w.x / 2., w.y),
|
||||||
|
FingerCheck::Start
|
||||||
|
).next() {
|
||||||
|
inputs.movement += (((finger.current_position - finger.start_position) / (w.x / 4.)) * dvec2(1., -1.)).as_vec2();
|
||||||
|
}
|
||||||
|
|
||||||
|
//Action buttons
|
||||||
|
let action_button_fingers = {
|
||||||
|
let mut action_button_fingers = SetU64::new();
|
||||||
|
|
||||||
|
//Creates iterator of fingers that started within action button area
|
||||||
|
let action_finger_iter = || touch_state.query_area(
|
||||||
|
dvec2(w.x * 0.75, w.y * 0.666),
|
||||||
|
dvec2(w.x * 0.25, w.y * 0.333),
|
||||||
|
FingerCheck::Start
|
||||||
|
);
|
||||||
|
|
||||||
|
//Action button A
|
||||||
|
inputs.action_a |= action_finger_iter().filter(|finger| finger.within_area(
|
||||||
|
dvec2(w.x * (0.75 + 0.125), w.y * 0.666),
|
||||||
|
dvec2(w.x * 0.125, w.y * 0.333),
|
||||||
|
FingerCheck::StartOrCurrent
|
||||||
|
)).map(|x| action_button_fingers.insert(x.id)).next().is_some();
|
||||||
|
|
||||||
|
//Action button B
|
||||||
|
inputs.action_b |= action_finger_iter().filter(|finger| finger.within_area(
|
||||||
|
dvec2(w.x * 0.75, w.y * 0.666),
|
||||||
|
dvec2(w.x * 0.125, w.y * 0.333),
|
||||||
|
FingerCheck::StartOrCurrent
|
||||||
|
)).map(|x| action_button_fingers.insert(x.id)).next().is_some();
|
||||||
|
|
||||||
|
action_button_fingers
|
||||||
|
};
|
||||||
|
|
||||||
|
//Camera controls
|
||||||
|
if let Some(finger) = touch_state.query_area(
|
||||||
|
dvec2(w.x / 2., 0.),
|
||||||
|
dvec2(w.x / 2., w.y),
|
||||||
|
FingerCheck::Start
|
||||||
|
).find(|x| !action_button_fingers.contains(x.id)) {
|
||||||
|
inputs.look += (((finger.current_position - finger.prev_position) / (w.x / 4.)) * 300.).as_vec2();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn input_end(
|
fn input_end(
|
||||||
mut inputs: UniqueViewMut<Inputs>,
|
mut inputs: UniqueViewMut<Inputs>,
|
||||||
) {
|
) {
|
||||||
|
@ -133,14 +275,17 @@ pub fn init_input (
|
||||||
storages.add_unique(Inputs::default());
|
storages.add_unique(Inputs::default());
|
||||||
storages.add_unique(PrevInputs::default());
|
storages.add_unique(PrevInputs::default());
|
||||||
storages.add_unique(RawKbmInputState::default());
|
storages.add_unique(RawKbmInputState::default());
|
||||||
|
storages.add_unique(RawTouchState::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn process_inputs() -> Workload {
|
pub fn process_inputs() -> Workload {
|
||||||
(
|
(
|
||||||
process_events,
|
process_events,
|
||||||
|
process_touch_events,
|
||||||
process_gilrs_events,
|
process_gilrs_events,
|
||||||
input_start,
|
input_start,
|
||||||
update_input_state,
|
update_input_state,
|
||||||
|
update_input_state_touch,
|
||||||
update_input_state_gamepad,
|
update_input_state_gamepad,
|
||||||
input_end,
|
input_end,
|
||||||
).into_sequential_workload()
|
).into_sequential_workload()
|
||||||
|
|
|
@ -76,7 +76,7 @@ fn override_loading(
|
||||||
kbm_state: UniqueView<RawKbmInputState>,
|
kbm_state: UniqueView<RawKbmInputState>,
|
||||||
mut state: UniqueViewMut<NextState>
|
mut state: UniqueViewMut<NextState>
|
||||||
) {
|
) {
|
||||||
if kbm_state.keyboard_state.contains(&VirtualKeyCode::F) {
|
if kbm_state.keyboard_state.contains(VirtualKeyCode::F as u32) {
|
||||||
state.0 = Some(GameState::InGame);
|
state.0 = Some(GameState::InGame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue