mirror of
https://github.com/kittywitch/gw2buttplug-rs.git
synced 2026-02-09 01:09:18 -08:00
initial commit
This commit is contained in:
commit
42ed3f2f8d
6 changed files with 3806 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
3442
Cargo.lock
generated
Normal file
3442
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "gw2buttplug-rs"
|
||||
authors = [ "kittywitch" ]
|
||||
description = "DPS to buttplug"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.95"
|
||||
buttplug = "9.0.6"
|
||||
log = "0.4.21"
|
||||
nexus = { git = "https://github.com/zerthox/nexus-rs", features = ["log", "arc", "extras"] }
|
||||
tokio = "1.43.0"
|
||||
arcdps = { git = "https://github.com/zerthox/arcdps-rs", tag = "0.15.1" }
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # nexus addons are dynamic system libraries (dll)
|
||||
BIN
src/icon-hover.png
Normal file
BIN
src/icon-hover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
BIN
src/icon.png
Normal file
BIN
src/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
346
src/lib.rs
Normal file
346
src/lib.rs
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
use nexus::{
|
||||
event::{arc::{CombatData, ACCOUNT_NAME, COMBAT_LOCAL}, event_subscribe, Event},
|
||||
event_consume,
|
||||
gui::{register_render, render, RenderType},
|
||||
imgui::{sys::cty::c_char, Window},
|
||||
keybind::{keybind_handler, register_keybind_with_string},
|
||||
paths::get_addon_dir,
|
||||
quick_access::add_quick_access,
|
||||
texture::{load_texture_from_file, texture_receive, Texture},
|
||||
AddonFlags, UpdateProvider,
|
||||
};
|
||||
use buttplug::{
|
||||
client::{device::ScalarValueCommand, ButtplugClientDevice, ButtplugClient, ButtplugClientError},
|
||||
core::{
|
||||
connector::{
|
||||
new_json_ws_client_connector,
|
||||
},
|
||||
message::{ClientGenericDeviceMessageAttributesV3},
|
||||
},
|
||||
};
|
||||
use tokio::{runtime, select, task::JoinSet};
|
||||
use tokio::sync::mpsc::{Receiver, Sender, channel, error::TryRecvError};
|
||||
use tokio::io::{self, AsyncBufReadExt, BufReader};
|
||||
use std::{cell::{Cell, Ref, RefCell}, collections::VecDeque, ffi::CStr, ptr, thread::{self, JoinHandle}};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Once;
|
||||
use arcdps::{evtc::event::{EnterCombatEvent, Event as arcEvent}, Agent, AgentOwned};
|
||||
use arcdps::Affinity;
|
||||
use std::sync::Arc;
|
||||
|
||||
static SENDER: OnceLock<Sender<ButtplugThreadEvent>> = OnceLock::new();
|
||||
static BP_THREAD: OnceLock<JoinHandle<()>> = OnceLock::new();
|
||||
|
||||
nexus::export! {
|
||||
name: "gw2buttplug-rs",
|
||||
signature: -0x12345678, // raidcore addon id or NEGATIVE random unique signature
|
||||
load,
|
||||
unload,
|
||||
flags: AddonFlags::None,
|
||||
provider: UpdateProvider::GitHub,
|
||||
update_link: "https://github.com/kittywitch/gw2buttplug-rs",
|
||||
log_filter: "debug"
|
||||
}
|
||||
|
||||
enum ButtplugThreadEvent {
|
||||
Connect(String),
|
||||
Disconnect,
|
||||
Quit,
|
||||
SetGoodDps(i64),
|
||||
CombatEvent{src: arcdps::AgentOwned, evt: arcEvent},
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Debug)]
|
||||
struct DamageEvent {
|
||||
time: i64,
|
||||
damage: i64,
|
||||
}
|
||||
|
||||
struct ButtplugState {
|
||||
agent: Option<AgentOwned>,
|
||||
average: i64,
|
||||
intensity: f64,
|
||||
good_dps: i64,
|
||||
combat_scenario: VecDeque<DamageEvent>,
|
||||
bp_client: ButtplugClient,
|
||||
}
|
||||
|
||||
impl ButtplugState {
|
||||
fn calculate_dps(&mut self) -> i64 {
|
||||
let oldest = self.combat_scenario.front();
|
||||
let latest = self.combat_scenario.back();
|
||||
let duration = match (oldest, latest) {
|
||||
(Some(oldest), Some(latest)) => latest.time - oldest.time,
|
||||
_ => {
|
||||
return 0
|
||||
},
|
||||
};
|
||||
let damage_sum = self.combat_scenario.iter().map(|e| e.damage).sum();
|
||||
match duration/1000 {
|
||||
0 => damage_sum,
|
||||
duration_s => damage_sum / duration_s,
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(&mut self) -> anyhow::Result<()> {
|
||||
if !self.combat_scenario.is_empty() {
|
||||
// calculate average
|
||||
self.average = self.calculate_dps();
|
||||
self.intensity = self.average as f64 / self.good_dps as f64;
|
||||
let intensity_scalar = ScalarValueCommand::ScalarValue(self.intensity);
|
||||
let mut js: JoinSet<_> = self.bp_client.devices().into_iter().map(|device| device.vibrate(&intensity_scalar)).collect();
|
||||
while let Some(res) = js.join_next().await {
|
||||
res??;
|
||||
}
|
||||
// calculate dps for testing
|
||||
log::info!("DPS for the latest minute: {}", self.average);
|
||||
log::info!("Intensity: {}", self.intensity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_event(&mut self, event: ButtplugThreadEvent) -> anyhow::Result<bool> {
|
||||
use ButtplugThreadEvent::*;
|
||||
match event {
|
||||
Connect(address) => {
|
||||
let connector = new_json_ws_client_connector(&address);
|
||||
log::info!("Connecting to {}", address);
|
||||
self.bp_client.connect(connector).await?;
|
||||
},
|
||||
Disconnect => {
|
||||
self.bp_client.disconnect().await?;
|
||||
log::info!("Disconnecting");
|
||||
},
|
||||
Quit => {
|
||||
if let Err(error) = self.bp_client.disconnect().await {
|
||||
log::error!("{}", error);
|
||||
}
|
||||
return Ok(false)
|
||||
},
|
||||
SetGoodDps(dps) => {
|
||||
self.good_dps = dps;
|
||||
},
|
||||
CombatEvent { src, evt } => {
|
||||
use arcdps::StateChange;
|
||||
let is_self = src.is_self != 0;
|
||||
if is_self {
|
||||
match &mut self.agent {
|
||||
Some(agent) if src.name != agent.name => {
|
||||
log::info!("Character changed from {:?} to {:?}!", agent.name, src.name);
|
||||
*agent = src;
|
||||
},
|
||||
Some(agent) => (),
|
||||
None => {
|
||||
log::info!("Character selected, {:?}!", src.name);
|
||||
self.agent = Some(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
match evt.get_statechange() {
|
||||
StateChange::None => {
|
||||
if is_self && evt.get_affinity() == Affinity::Foe {
|
||||
let event_time = evt.time as i64;
|
||||
log::info!("You did {} damage at {}!", -evt.value, evt.time);
|
||||
// check for all items prior to a minute ago
|
||||
let rem_partition_idx = self.combat_scenario.partition_point(|e| e.time <= event_time - (3600*1000));
|
||||
self.combat_scenario = self.combat_scenario.split_off(rem_partition_idx);
|
||||
// adding an item
|
||||
let add_partition_idx = self.combat_scenario.partition_point(|e| e.time <= event_time);
|
||||
self.combat_scenario.insert(add_partition_idx, DamageEvent { time: event_time, damage: -evt.value as i64});
|
||||
}
|
||||
},
|
||||
StateChange::EnterCombat => {
|
||||
log::info!("Combat begins at {}!", evt.time);
|
||||
},
|
||||
StateChange::ExitCombat => {
|
||||
log::info!("Combat ends at {}!", evt.time);
|
||||
self.combat_scenario.clear();
|
||||
self.intensity = 0.0;
|
||||
self.average = 0;
|
||||
let intensity_scalar = ScalarValueCommand::ScalarValue(0.0);
|
||||
let mut js: JoinSet<_> = self.bp_client.devices().into_iter().map(|device| device.vibrate(&intensity_scalar)).collect();
|
||||
while let Some(res) = js.join_next().await {
|
||||
res??;
|
||||
}
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_buttplug(mut bp_receiver: Receiver<ButtplugThreadEvent>) {
|
||||
let bp_client = ButtplugClient::new("GW2Buttplug-rs");
|
||||
let mut state = ButtplugState {
|
||||
agent: None,
|
||||
average: 0,
|
||||
intensity: 0.0,
|
||||
good_dps: 10000,
|
||||
combat_scenario: VecDeque::new(),
|
||||
bp_client: bp_client,
|
||||
};
|
||||
let evt_loop = async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(250));
|
||||
loop {
|
||||
select! {
|
||||
evt = bp_receiver.recv() => match evt {
|
||||
Some(evt) => {
|
||||
match state.handle_event(evt).await {
|
||||
Ok(true) => (),
|
||||
Ok(false) => break,
|
||||
Err(error) => {
|
||||
log::error!("Error! {}", error)
|
||||
}
|
||||
}
|
||||
},
|
||||
None => {
|
||||
// presumably if the application is quitting, we want to shut off the buttplug client connection
|
||||
// we unwrap because we don't really care what happens there
|
||||
state.bp_client.disconnect().await.unwrap();
|
||||
// break the event loop so the thread can exit
|
||||
break
|
||||
},
|
||||
},
|
||||
_ = interval.tick() => {
|
||||
// does stuff every second
|
||||
state.tick().await;
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
let rt = match runtime::Builder::new_current_thread().enable_all().build() {
|
||||
Ok(rt) => rt,
|
||||
Err(error) => {
|
||||
log::error!("Error! {}", error);
|
||||
return
|
||||
},
|
||||
|
||||
};
|
||||
rt.block_on(evt_loop);
|
||||
}
|
||||
|
||||
fn load() {
|
||||
log::info!("Loading addon");
|
||||
let intiface_server_default = "ws://localhost:12345";
|
||||
let addon_dir = get_addon_dir("buttplug").expect("invalid addon dir");
|
||||
let (event_sender, event_receiver) = channel::<ButtplugThreadEvent>(32);
|
||||
let bp_handler = thread::spawn(|| { load_buttplug(event_receiver) });
|
||||
BP_THREAD.set(bp_handler);
|
||||
SENDER.set(event_sender);
|
||||
|
||||
register_render(
|
||||
RenderType::Render,
|
||||
render!(|ui| {
|
||||
Window::new("Buttplug Control").build(ui, || {
|
||||
static START: Once = Once::new();
|
||||
// this is fine since imgui is single threaded
|
||||
thread_local! {
|
||||
static CONNECT: Cell<bool> = const { Cell::new(false) };
|
||||
static INTIFACE_ADDRESS: RefCell<String> = const { RefCell::new(String::new())};
|
||||
static GOOD_DPS: RefCell<i32> = const { RefCell::new(10000i32) };
|
||||
}
|
||||
|
||||
START.call_once(|| {
|
||||
INTIFACE_ADDRESS.with_borrow_mut(|address|
|
||||
if address.is_empty() {
|
||||
*address = "ws://localhost:12345".to_string();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
INTIFACE_ADDRESS.with_borrow_mut(|address| {
|
||||
let intiface_address = ui.input_text("Intiface Address", address).build();
|
||||
});
|
||||
|
||||
let connect = CONNECT.get();
|
||||
if connect {
|
||||
let sender = SENDER.get().unwrap();
|
||||
if ui.button("Disconnect") {
|
||||
sender.try_send(ButtplugThreadEvent::Disconnect);
|
||||
}
|
||||
|
||||
} else {
|
||||
if ui.button("Connect") {
|
||||
let intiface_address_final = INTIFACE_ADDRESS.with_borrow(|address| {
|
||||
address.clone()
|
||||
});
|
||||
let sender = SENDER.get().unwrap();
|
||||
sender.try_send(ButtplugThreadEvent::Connect(intiface_address_final));
|
||||
}
|
||||
}
|
||||
|
||||
GOOD_DPS.with_borrow_mut(|dps| {
|
||||
let good_dps = ui.input_int("Good DPS", dps).build();
|
||||
});
|
||||
if ui.button("Save DPS") {
|
||||
let good_dps_final = GOOD_DPS.with_borrow(|dps| {
|
||||
dps.clone()
|
||||
});
|
||||
let sender = SENDER.get().unwrap();
|
||||
sender.try_send(ButtplugThreadEvent::SetGoodDps(good_dps_final as i64));
|
||||
}
|
||||
|
||||
});
|
||||
}),
|
||||
)
|
||||
.revert_on_unload();
|
||||
|
||||
let combat_callback = event_consume!(|cdata: Option<&CombatData>| {
|
||||
let sender = SENDER.get().unwrap();
|
||||
if let Some(combat_data) = cdata {
|
||||
if let Some(evt) = combat_data.event() {
|
||||
if let Some(agt) = combat_data.src() {
|
||||
let agt = AgentOwned::from(unsafe { ptr::read(agt) });
|
||||
sender.try_send(ButtplugThreadEvent::CombatEvent {src: agt, evt: evt.clone()});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
COMBAT_LOCAL.subscribe(combat_callback).revert_on_unload();
|
||||
|
||||
let receive_texture =
|
||||
texture_receive!(|id: &str, _texture: Option<&Texture>| log::info!("texture {id} loaded"));
|
||||
load_texture_from_file("BUTTTPLUG_ICON", addon_dir.join("icon.png"), Some(receive_texture));
|
||||
load_texture_from_file(
|
||||
"BUTTTPLUG_ICON_HOVER",
|
||||
addon_dir.join("icon_hover.png"),
|
||||
Some(receive_texture),
|
||||
);
|
||||
|
||||
add_quick_access(
|
||||
"Buttplug Control",
|
||||
"BUTTTPLUG_ICON",
|
||||
"BUTTTPLUG_ICON_HOVER",
|
||||
"BUTTPLUG_MENU_KEYBIND",
|
||||
"Open buttplug control menu",
|
||||
)
|
||||
.revert_on_unload();
|
||||
|
||||
let keybind_handler = keybind_handler!(|id, is_release| log::info!(
|
||||
"Keybind {id} {}",
|
||||
if is_release { "released" } else { "pressed" }
|
||||
));
|
||||
register_keybind_with_string("BUTTPLUG_MENU_KEYBIND", keybind_handler, "ALT+SHIFT+T").revert_on_unload();
|
||||
|
||||
unsafe { event_subscribe!("MY_EVENT" => i32, |data| log::info!("Received event {data:?}")) }
|
||||
.revert_on_unload();
|
||||
|
||||
ACCOUNT_NAME
|
||||
.subscribe(event_consume!(<c_char> |name| {
|
||||
if let Some(name) = name {
|
||||
let name = unsafe {CStr::from_ptr(name as *const c_char)};
|
||||
log::info!("Received account name: {name:?}");
|
||||
}
|
||||
}))
|
||||
.revert_on_unload();
|
||||
}
|
||||
|
||||
fn unload() {
|
||||
log::info!("Unloading addon");
|
||||
// all actions passed to on_load() or revert_on_unload() are performed automatically
|
||||
let sender = SENDER.get().unwrap();
|
||||
sender.try_send(ButtplugThreadEvent::Quit);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue