Compare commits

...

7 Commits

33 changed files with 903 additions and 339 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "friendolls-desktop",
"version": "0.1.0",
"version": "0.1.3",
"description": "",
"type": "module",
"scripts": {

View File

@@ -1,9 +1,11 @@
use crate::{
lock_r,
models::app_data::UserData,
models::{app_data::UserData, app_state::{AppState, NekoPosition}},
services::{
app_data::{init_app_data_scoped, AppDataRefreshScope},
app_state,
friends,
neko_positions,
presence_modules::models::ModuleMetadata,
sprite,
},
@@ -45,3 +47,33 @@ pub fn get_friend_active_doll_sprites_base64() -> Result<friends::FriendActiveDo
friends::sync_active_doll_sprites_from_app_data();
Ok(friends::get_active_doll_sprites_snapshot())
}
#[tauri::command]
#[specta::specta]
pub fn get_app_state() -> Result<AppState, String> {
Ok(app_state::get_snapshot())
}
#[tauri::command]
#[specta::specta]
pub fn get_neko_positions() -> Result<neko_positions::NekoPositionsDto, String> {
Ok(neko_positions::get_snapshot())
}
#[tauri::command]
#[specta::specta]
pub fn set_scene_setup_nekos_position(nekos_position: Option<NekoPosition>) {
app_state::set_scene_setup_nekos_position(nekos_position);
}
#[tauri::command]
#[specta::specta]
pub fn set_scene_setup_nekos_opacity(nekos_opacity: f32) {
app_state::set_scene_setup_nekos_opacity(nekos_opacity);
}
#[tauri::command]
#[specta::specta]
pub fn set_scene_setup_nekos_scale(nekos_scale: f32) {
app_state::set_scene_setup_nekos_scale(nekos_scale);
}

View File

@@ -1,14 +1,12 @@
use crate::{
commands::app_state::get_modules,
services::{
use crate::services::{
doll_editor::open_doll_editor_window,
scene::{get_scene_interactive, set_pet_menu_state, set_scene_interactive},
},
};
use commands::app::{quit_app, restart_app, retry_connection};
use commands::app_state::{
get_active_doll_sprite_base64, get_app_data, get_friend_active_doll_sprites_base64,
refresh_app_data,
get_active_doll_sprite_base64, get_app_data, get_app_state, get_neko_positions,
get_friend_active_doll_sprites_base64, get_modules, refresh_app_data,
set_scene_setup_nekos_opacity, set_scene_setup_nekos_position, set_scene_setup_nekos_scale,
};
use commands::auth::{logout_and_restart, start_discord_auth, start_google_auth};
use commands::config::{get_client_config, open_client_config, save_client_config};
@@ -27,10 +25,10 @@ use tauri::async_runtime;
use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, ErrorHandlingMode};
use crate::services::app_events::{
ActiveDollSpriteChanged, AppDataRefreshed, AuthFlowUpdated, CreateDoll, CursorMoved, EditDoll,
FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendCursorPositionsUpdated,
FriendDisconnected, FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived,
FriendUserStatusChanged, InteractionDeliveryFailed, InteractionReceived,
ActiveDollSpriteChanged, AppDataRefreshed, AppStateChanged, AuthFlowUpdated, CreateDoll,
EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendDisconnected,
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
InteractionDeliveryFailed, InteractionReceived, NekoPositionsUpdated,
SceneInteractiveChanged, SetInteractionOverlay, Unfriended, UserStatusChanged,
};
@@ -68,6 +66,8 @@ pub fn run() {
.error_handling(ErrorHandlingMode::Throw)
.commands(collect_commands![
get_app_data,
get_app_state,
get_neko_positions,
get_active_doll_sprite_base64,
get_friend_active_doll_sprites_base64,
refresh_app_data,
@@ -102,18 +102,21 @@ pub fn run() {
start_discord_auth,
logout_and_restart,
send_interaction_cmd,
get_modules
get_modules,
set_scene_setup_nekos_position,
set_scene_setup_nekos_opacity,
set_scene_setup_nekos_scale
])
.events(collect_events![
CursorMoved,
SceneInteractiveChanged,
AppDataRefreshed,
AppStateChanged,
NekoPositionsUpdated,
ActiveDollSpriteChanged,
SetInteractionOverlay,
EditDoll,
CreateDoll,
UserStatusChanged,
FriendCursorPositionsUpdated,
FriendDisconnected,
FriendActiveDollChanged,
FriendActiveDollSpritesUpdated,

View File

@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum NekoPosition {
TopLeft,
Top,
TopRight,
Left,
Right,
BottomLeft,
Bottom,
BottomRight,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SceneSetup {
pub nekos_position: Option<NekoPosition>,
pub nekos_opacity: f32,
pub nekos_scale: f32,
}
impl Default for SceneSetup {
fn default() -> Self {
Self {
nekos_position: None,
nekos_opacity: 1.0,
nekos_scale: 1.0,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type, Default)]
#[serde(rename_all = "camelCase")]
pub struct AppState {
pub scene_setup: SceneSetup,
}

View File

@@ -1,4 +1,5 @@
pub mod app_data;
pub mod app_state;
pub mod dolls;
pub mod event_payloads;
pub mod friends;

View File

@@ -11,7 +11,7 @@ use crate::{
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
services::{
app_events::{ActiveDollSpriteChanged, AppDataRefreshed},
friends, sprite,
friends, neko_positions, sprite,
},
state::FDOLL,
};
@@ -51,6 +51,8 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
Ok(user) => {
let mut guard = lock_w!(FDOLL);
guard.user_data.user = Some(user);
drop(guard);
neko_positions::sync_from_app_data();
}
Err(error) => {
warn!("Failed to fetch user profile: {}", error);

View File

@@ -5,6 +5,7 @@ use tauri_specta::Event;
use crate::{
models::{
app_data::UserData,
app_state::AppState,
event_payloads::{
FriendActiveDollChangedPayload, FriendDisconnectedPayload,
FriendRequestAcceptedPayload, FriendRequestDeniedPayload, FriendRequestReceivedPayload,
@@ -12,10 +13,7 @@ use crate::{
},
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
},
services::{
cursor::CursorPositions,
friends::{FriendActiveDollSpritesDto, FriendCursorPositionsDto},
},
services::{friends::FriendActiveDollSpritesDto, neko_positions::NekoPositionsDto},
};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
@@ -35,10 +33,6 @@ pub struct AuthFlowUpdatedPayload {
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "cursor-position")]
pub struct CursorMoved(pub CursorPositions);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "scene-interactive")]
pub struct SceneInteractiveChanged(pub bool);
@@ -47,6 +41,10 @@ pub struct SceneInteractiveChanged(pub bool);
#[tauri_specta(event_name = "app-data-refreshed")]
pub struct AppDataRefreshed(pub UserData);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "app-state-changed")]
pub struct AppStateChanged(pub AppState);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "active-doll-sprite-changed")]
pub struct ActiveDollSpriteChanged(pub Option<String>);
@@ -68,8 +66,8 @@ pub struct CreateDoll;
pub struct UserStatusChanged(pub UserStatusPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-cursor-positions")]
pub struct FriendCursorPositionsUpdated(pub FriendCursorPositionsDto);
#[tauri_specta(event_name = "neko-positions")]
pub struct NekoPositionsUpdated(pub NekoPositionsDto);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-disconnected")]

View File

@@ -0,0 +1,44 @@
use std::sync::{Arc, LazyLock, RwLock};
use tauri_specta::Event as _;
use tracing::warn;
use crate::{
get_app_handle, lock_r, lock_w,
models::app_state::{AppState, NekoPosition},
services::{app_events::AppStateChanged, neko_positions},
};
static APP_STATE: LazyLock<Arc<RwLock<AppState>>> =
LazyLock::new(|| Arc::new(RwLock::new(AppState::default())));
pub fn get_snapshot() -> AppState {
let guard = lock_r!(APP_STATE);
guard.clone()
}
pub fn set_scene_setup_nekos_position(nekos_position: Option<NekoPosition>) {
let mut guard = lock_w!(APP_STATE);
guard.scene_setup.nekos_position = nekos_position;
emit_snapshot(&guard);
drop(guard);
neko_positions::refresh_from_scene_setup();
}
pub fn set_scene_setup_nekos_opacity(nekos_opacity: f32) {
let mut guard = lock_w!(APP_STATE);
guard.scene_setup.nekos_opacity = nekos_opacity.clamp(0.1, 1.0);
emit_snapshot(&guard);
}
pub fn set_scene_setup_nekos_scale(nekos_scale: f32) {
let mut guard = lock_w!(APP_STATE);
guard.scene_setup.nekos_scale = nekos_scale.clamp(0.5, 2.0);
emit_snapshot(&guard);
}
fn emit_snapshot(app_state: &AppState) {
if let Err(error) = AppStateChanged(app_state.clone()).emit(get_app_handle()) {
warn!("Failed to emit app-state-changed event: {}", error);
}
}

View File

@@ -7,8 +7,11 @@ use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::{get_app_handle, lock_r, services::app_events::CursorMoved, state::FDOLL};
use tauri_specta::Event as _;
use crate::{
lock_r,
services::{neko_positions, ws::report_cursor_data},
state::FDOLL,
};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -59,8 +62,7 @@ pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition {
}
}
/// Initialize cursor tracking. Broadcasts cursor
/// position changes via `cursor-position` event.
/// Initialize cursor tracking.
pub async fn init_cursor_tracking() {
info!("start_cursor_tracking called");
@@ -88,22 +90,19 @@ async fn init_cursor_tracking_i() -> Result<(), String> {
let (tx, mut rx) = mpsc::channel::<CursorPositions>(100);
// Spawn the consumer task
// This task handles WebSocket reporting and local broadcasting.
// This task handles WebSocket reporting and local position projection updates.
// It runs independently of the device event loop.
tauri::async_runtime::spawn(async move {
info!("Cursor event consumer started");
let app_handle = get_app_handle();
while let Some(positions) = rx.recv().await {
let mapped_for_ws = positions.mapped.clone();
// 1. WebSocket reporting
crate::services::ws::report_cursor_data(mapped_for_ws).await;
report_cursor_data(mapped_for_ws).await;
// 2. Broadcast to local windows
if let Err(e) = CursorMoved(positions).emit(app_handle) {
error!("Failed to emit cursor position event: {:?}", e);
}
// 2. Update unified neko positions projection
neko_positions::update_self_cursor(positions);
}
warn!("Cursor event consumer stopped (channel closed)");
});

View File

@@ -8,7 +8,7 @@ use specta::Type;
use tauri_specta::Event as _;
use crate::{
get_app_handle, lock_r,
get_app_handle, lock_r, lock_w,
models::{dolls::DollDto, friends::FriendshipResponseDto},
services::{app_events::FriendActiveDollSpritesUpdated, sprite},
state::FDOLL,
@@ -29,27 +29,21 @@ pub fn sync_from_app_data() {
let next = build_sprites(&friends);
let mut projection = FRIEND_ACTIVE_DOLL_SPRITES
.write()
.expect("friend active doll sprite projection lock poisoned");
let mut projection = lock_w!(FRIEND_ACTIVE_DOLL_SPRITES);
*projection = next;
emit_snapshot(&projection);
}
pub fn clear() {
let mut projection = FRIEND_ACTIVE_DOLL_SPRITES
.write()
.expect("friend active doll sprite projection lock poisoned");
let mut projection = lock_w!(FRIEND_ACTIVE_DOLL_SPRITES);
projection.clear();
emit_snapshot(&projection);
}
pub fn remove_friend(user_id: &str) {
let mut projection = FRIEND_ACTIVE_DOLL_SPRITES
.write()
.expect("friend active doll sprite projection lock poisoned");
let mut projection = lock_w!(FRIEND_ACTIVE_DOLL_SPRITES);
if projection.remove(user_id).is_some() {
emit_snapshot(&projection);
@@ -57,9 +51,7 @@ pub fn remove_friend(user_id: &str) {
}
pub fn set_active_doll(user_id: &str, doll: Option<&DollDto>) {
let mut projection = FRIEND_ACTIVE_DOLL_SPRITES
.write()
.expect("friend active doll sprite projection lock poisoned");
let mut projection = lock_w!(FRIEND_ACTIVE_DOLL_SPRITES);
match doll {
Some(doll) => match sprite::encode_doll_sprite_base64(doll) {
@@ -88,9 +80,7 @@ pub fn set_active_doll(user_id: &str, doll: Option<&DollDto>) {
}
pub fn get_snapshot() -> FriendActiveDollSpritesDto {
let projection = FRIEND_ACTIVE_DOLL_SPRITES
.read()
.expect("friend active doll sprite projection lock poisoned");
let projection = lock_r!(FRIEND_ACTIVE_DOLL_SPRITES);
FriendActiveDollSpritesDto(projection.clone())
}

View File

@@ -1,145 +0,0 @@
use std::{
collections::HashMap,
sync::{Arc, LazyLock, RwLock},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event as _;
use crate::{
get_app_handle, lock_r,
services::{app_events::FriendCursorPositionsUpdated, cursor::CursorPositions},
state::FDOLL,
};
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(transparent)]
pub struct FriendCursorPositionsDto(pub HashMap<String, CursorPositions>);
#[derive(Default)]
struct FriendCursorProjection {
active_dolls: HashMap<String, bool>,
positions: HashMap<String, CursorPositions>,
}
static FRIEND_CURSOR_PROJECTION: LazyLock<Arc<RwLock<FriendCursorProjection>>> =
LazyLock::new(|| Arc::new(RwLock::new(FriendCursorProjection::default())));
pub fn sync_from_app_data() {
let friends = {
let guard = lock_r!(FDOLL);
guard.user_data.friends.clone().unwrap_or_default()
};
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
projection.active_dolls = friends
.into_iter()
.filter_map(|friendship| {
friendship.friend.map(|friend| {
let has_active_doll = friend.active_doll.is_some();
(friend.id, has_active_doll)
})
})
.collect();
let active_dolls = projection.active_dolls.clone();
projection
.positions
.retain(|user_id, _| active_dolls.get(user_id) == Some(&true));
emit_snapshot(&projection.positions);
}
pub fn clear() {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
projection.active_dolls.clear();
projection.positions.clear();
emit_snapshot(&projection.positions);
}
pub fn update_position(user_id: String, position: CursorPositions) {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
if !has_active_doll(&mut projection, &user_id) {
if projection.positions.remove(&user_id).is_some() {
emit_snapshot(&projection.positions);
}
return;
}
projection.positions.insert(user_id, position);
emit_snapshot(&projection.positions);
}
pub fn remove_friend(user_id: &str) {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
let removed_active_doll = projection.active_dolls.remove(user_id).is_some();
let removed_position = projection.positions.remove(user_id).is_some();
if removed_active_doll || removed_position {
emit_snapshot(&projection.positions);
}
}
pub fn set_active_doll(user_id: &str, has_active_doll: bool) {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
projection
.active_dolls
.insert(user_id.to_string(), has_active_doll);
if !has_active_doll && projection.positions.remove(user_id).is_some() {
emit_snapshot(&projection.positions);
}
}
fn has_active_doll(projection: &mut FriendCursorProjection, user_id: &str) -> bool {
if let Some(has_active_doll) = projection.active_dolls.get(user_id) {
return *has_active_doll;
}
let has_active_doll = {
let guard = lock_r!(FDOLL);
guard
.user_data
.friends
.as_ref()
.and_then(|friends| {
friends.iter().find_map(|friendship| {
let friend = friendship.friend.as_ref()?;
(friend.id == user_id).then_some(friend)
})
})
.and_then(|friend| friend.active_doll.as_ref())
.is_some()
};
projection
.active_dolls
.insert(user_id.to_string(), has_active_doll);
has_active_doll
}
fn emit_snapshot(positions: &HashMap<String, CursorPositions>) {
let payload = FriendCursorPositionsDto(positions.clone());
if let Err(err) = FriendCursorPositionsUpdated(payload).emit(get_app_handle()) {
tracing::warn!("Failed to emit friend cursor positions update: {}", err);
}
}

View File

@@ -1,33 +1,27 @@
mod active_doll_sprites;
mod cursor_positions;
use crate::{models::dolls::DollDto, services::cursor::CursorPositions};
use crate::{models::dolls::DollDto, services::neko_positions};
pub use active_doll_sprites::FriendActiveDollSpritesDto;
pub use cursor_positions::FriendCursorPositionsDto;
pub fn sync_from_app_data() {
active_doll_sprites::sync_from_app_data();
cursor_positions::sync_from_app_data();
neko_positions::sync_from_app_data();
}
pub fn clear() {
active_doll_sprites::clear();
cursor_positions::clear();
neko_positions::clear();
}
pub fn remove_friend(user_id: &str) {
active_doll_sprites::remove_friend(user_id);
cursor_positions::remove_friend(user_id);
neko_positions::remove_friend(user_id);
}
pub fn set_active_doll(user_id: &str, doll: Option<&DollDto>) {
active_doll_sprites::set_active_doll(user_id, doll);
cursor_positions::set_active_doll(user_id, doll.is_some());
}
pub fn update_cursor_position(user_id: String, position: CursorPositions) {
cursor_positions::update_position(user_id, position);
neko_positions::set_friend_active_doll(user_id, doll.is_some());
}
pub fn sync_active_doll_sprites_from_app_data() {

View File

@@ -1,5 +1,6 @@
pub mod app_data;
pub mod app_events;
pub mod app_state;
pub mod app_menu;
pub mod app_update;
pub mod accelerators;
@@ -11,6 +12,7 @@ pub mod friends;
pub mod health_manager;
pub mod health_monitor;
pub mod interaction;
pub mod neko_positions;
pub mod petpet;
pub mod presence_modules;
pub mod scene;

View File

@@ -0,0 +1,291 @@
use std::{
collections::HashMap,
sync::{Arc, LazyLock, RwLock},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event as _;
use crate::{
get_app_handle, lock_r, lock_w,
models::app_state::NekoPosition,
services::{
app_events::NekoPositionsUpdated,
app_state,
cursor::{CursorPosition, CursorPositions},
},
state::FDOLL,
};
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NekoPositionDto {
pub user_id: String,
pub is_self: bool,
pub cursor: CursorPositions,
pub target: CursorPosition,
pub override_applied: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(transparent)]
pub struct NekoPositionsDto(pub HashMap<String, NekoPositionDto>);
#[derive(Default)]
struct NekoPositionsProjection {
self_cursor: Option<CursorPositions>,
friend_cursors: HashMap<String, CursorPositions>,
friend_active_dolls: HashMap<String, bool>,
}
static NEKO_POSITIONS: LazyLock<Arc<RwLock<NekoPositionsProjection>>> =
LazyLock::new(|| Arc::new(RwLock::new(NekoPositionsProjection::default())));
pub fn sync_from_app_data() {
let friends = {
let guard = lock_r!(FDOLL);
guard.user_data.friends.clone().unwrap_or_default()
};
let mut projection = lock_w!(NEKO_POSITIONS);
projection.friend_active_dolls = friends
.into_iter()
.filter_map(|friendship| {
friendship.friend.map(|friend| {
let has_active_doll = friend.active_doll.is_some();
(friend.id, has_active_doll)
})
})
.collect();
let active_dolls = projection.friend_active_dolls.clone();
projection
.friend_cursors
.retain(|user_id, _| active_dolls.get(user_id) == Some(&true));
emit_snapshot(&projection);
}
pub fn clear() {
let mut projection = lock_w!(NEKO_POSITIONS);
projection.self_cursor = None;
projection.friend_cursors.clear();
projection.friend_active_dolls.clear();
emit_snapshot(&projection);
}
pub fn update_self_cursor(position: CursorPositions) {
let mut projection = lock_w!(NEKO_POSITIONS);
projection.self_cursor = Some(position);
emit_snapshot(&projection);
}
pub fn update_friend_cursor(user_id: String, position: CursorPositions) {
let mut projection = lock_w!(NEKO_POSITIONS);
if !has_friend_active_doll(&mut projection, &user_id) {
if projection.friend_cursors.remove(&user_id).is_some() {
emit_snapshot(&projection);
}
return;
}
projection.friend_cursors.insert(user_id, position);
emit_snapshot(&projection);
}
pub fn remove_friend(user_id: &str) {
let mut projection = lock_w!(NEKO_POSITIONS);
let removed_active_doll = projection.friend_active_dolls.remove(user_id).is_some();
let removed_position = projection.friend_cursors.remove(user_id).is_some();
if removed_active_doll || removed_position {
emit_snapshot(&projection);
}
}
pub fn set_friend_active_doll(user_id: &str, has_active_doll: bool) {
let mut projection = lock_w!(NEKO_POSITIONS);
projection
.friend_active_dolls
.insert(user_id.to_string(), has_active_doll);
if !has_active_doll && projection.friend_cursors.remove(user_id).is_some() {
emit_snapshot(&projection);
}
}
pub fn refresh_from_scene_setup() {
let projection = lock_r!(NEKO_POSITIONS);
emit_snapshot(&projection);
}
pub fn get_snapshot() -> NekoPositionsDto {
let projection = lock_r!(NEKO_POSITIONS);
build_snapshot(&projection)
}
fn has_friend_active_doll(projection: &mut NekoPositionsProjection, user_id: &str) -> bool {
if let Some(has_active_doll) = projection.friend_active_dolls.get(user_id) {
return *has_active_doll;
}
let has_active_doll = {
let guard = lock_r!(FDOLL);
guard
.user_data
.friends
.as_ref()
.and_then(|friends| {
friends.iter().find_map(|friendship| {
let friend = friendship.friend.as_ref()?;
(friend.id == user_id).then_some(friend)
})
})
.and_then(|friend| friend.active_doll.as_ref())
.is_some()
};
projection
.friend_active_dolls
.insert(user_id.to_string(), has_active_doll);
has_active_doll
}
fn has_self_active_doll() -> bool {
let guard = lock_r!(FDOLL);
guard
.user_data
.user
.as_ref()
.and_then(|user| user.active_doll_id.as_ref())
.is_some()
}
fn get_self_user_id() -> Option<String> {
let guard = lock_r!(FDOLL);
guard.user_data.user.as_ref().map(|user| user.id.clone())
}
fn get_display_size() -> (f64, f64) {
let guard = lock_r!(FDOLL);
(
guard.user_data.scene.display.screen_width as f64,
guard.user_data.scene.display.screen_height as f64,
)
}
fn emit_snapshot(projection: &NekoPositionsProjection) {
let payload = build_snapshot(projection);
if let Err(err) = NekoPositionsUpdated(payload).emit(get_app_handle()) {
tracing::warn!("Failed to emit neko positions update: {}", err);
}
}
fn build_snapshot(projection: &NekoPositionsProjection) -> NekoPositionsDto {
let mut entries: Vec<(String, bool, CursorPositions)> = Vec::new();
if has_self_active_doll() {
if let (Some(self_user_id), Some(self_cursor)) =
(get_self_user_id(), projection.self_cursor.clone())
{
entries.push((self_user_id, true, self_cursor));
}
}
for (user_id, cursor) in &projection.friend_cursors {
if projection.friend_active_dolls.get(user_id) == Some(&true) {
entries.push((user_id.clone(), false, cursor.clone()));
}
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
let app_state = app_state::get_snapshot();
let override_anchor = app_state.scene_setup.nekos_position;
let (screen_width, screen_height) = get_display_size();
let total = entries.len();
NekoPositionsDto(
entries
.into_iter()
.enumerate()
.map(|(index, (user_id, is_self, cursor))| {
let (target, override_applied) = match &override_anchor {
Some(anchor) => (
get_cluster_target(
anchor.clone(),
index,
total,
screen_width,
screen_height,
),
true,
),
None => (cursor.raw.clone(), false),
};
(
user_id.clone(),
NekoPositionDto {
user_id,
is_self,
cursor,
target,
override_applied,
},
)
})
.collect(),
)
}
fn get_cluster_target(
anchor: NekoPosition,
index: usize,
count: usize,
screen_width: f64,
screen_height: f64,
) -> CursorPosition {
let spacing = 36.0;
let margin = 28.0;
let columns = (count as f64).sqrt().ceil().max(1.0) as usize;
let rows = count.div_ceil(columns).max(1);
let col = index % columns;
let row = index / columns;
let block_width = (columns.saturating_sub(1)) as f64 * spacing;
let block_height = (rows.saturating_sub(1)) as f64 * spacing;
let start_x = match anchor {
NekoPosition::TopLeft | NekoPosition::Left | NekoPosition::BottomLeft => margin,
NekoPosition::Top | NekoPosition::Bottom => (screen_width - block_width) / 2.0,
NekoPosition::TopRight | NekoPosition::Right | NekoPosition::BottomRight => {
screen_width - margin - block_width
}
};
let start_y = match anchor {
NekoPosition::TopLeft | NekoPosition::Top | NekoPosition::TopRight => margin,
NekoPosition::Left | NekoPosition::Right => (screen_height - block_height) / 2.0,
NekoPosition::BottomLeft | NekoPosition::Bottom | NekoPosition::BottomRight => {
screen_height - margin - block_height
}
};
CursorPosition {
x: (start_x + col as f64 * spacing).clamp(0.0, screen_width),
y: (start_y + row as f64 * spacing).clamp(0.0, screen_height),
}
}

View File

@@ -13,7 +13,7 @@ use crate::services::app_events::{
};
use crate::services::{
cursor::{normalized_to_absolute, CursorPositions},
friends,
friends, neko_positions,
};
use super::{emitter, refresh, types::IncomingFriendCursorPayload, utils};
@@ -62,13 +62,12 @@ pub fn on_friend_cursor_position(payload: Payload, _socket: RawClient) {
let mapped_pos = &friend_data.position;
let raw_pos = normalized_to_absolute(mapped_pos);
friends::update_cursor_position(
friend_data.user_id,
CursorPositions {
let position = CursorPositions {
raw: raw_pos,
mapped: mapped_pos.clone(),
},
);
};
neko_positions::update_friend_cursor(friend_data.user_id, position);
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "friendolls-desktop",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "com.adamcv.friendolls-desktop",
"build": {
"beforeDevCommand": "pnpm dev",

View File

@@ -77,6 +77,15 @@
background-color: transparent;
}
* {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Standard property */
}
.pixelated {
image-rendering: pixelated;
}

View File

@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-image-icon lucide-image"
><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle
cx="9"
cy="9"
r="2"
/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" /></svg
>

After

Width:  |  Height:  |  Size: 413 B

32
src/events/app-state.ts Normal file
View File

@@ -0,0 +1,32 @@
import { writable } from "svelte/store";
import {
commands,
events,
type AppState,
type NekoPosition,
} from "$lib/bindings";
import { createEventSource } from "./listener-utils";
export type NeksPosition = NekoPosition;
export type { AppState };
const initialState: AppState = {
sceneSetup: {
nekosPosition: null,
nekosOpacity: 1,
nekosScale: 1,
},
};
export const appState = writable<AppState>(initialState);
export const { start: startAppState, stop: stopAppState } = createEventSource(
async (addEventListener) => {
appState.set(await commands.getAppState());
addEventListener(
await events.appStateChanged.listen((event) => {
appState.set(event.payload);
}),
);
},
);

View File

@@ -1,17 +0,0 @@
import { writable } from "svelte/store";
import { events, type CursorPositions } from "$lib/bindings";
import { createEventSource } from "./listener-utils";
export const cursorPositionOnScreen = writable<CursorPositions>({
raw: { x: 0, y: 0 },
mapped: { x: 0, y: 0 },
});
export const { start: startCursorTracking, stop: stopCursorTracking } =
createEventSource(async (addEventListener) => {
addEventListener(
await events.cursorMoved.listen((event) => {
cursorPositionOnScreen.set(event.payload);
}),
);
});

View File

@@ -1,32 +0,0 @@
import { writable } from "svelte/store";
import { events, type CursorPositions } from "$lib/bindings";
import { createEventSource } from "./listener-utils";
export const friendsCursorPositions = writable<Record<string, CursorPositions>>(
{},
);
// Here for now. Will extract into shared
// util when there's more similar cases.
function toCursorPositionsRecord(
payload: Partial<Record<string, CursorPositions>>,
): Record<string, CursorPositions> {
return Object.fromEntries(
Object.entries(payload).filter(
(entry): entry is [string, CursorPositions] => {
return entry[1] !== undefined;
},
),
);
}
export const {
start: startFriendCursorTracking,
stop: stopFriendCursorTracking,
} = createEventSource(async (addEventListener) => {
addEventListener(
await events.friendCursorPositionsUpdated.listen((event) => {
friendsCursorPositions.set(toCursorPositionsRecord(event.payload));
}),
);
});

View File

@@ -0,0 +1,16 @@
import { writable } from "svelte/store";
import { commands, events, type NekoPositionsDto } from "$lib/bindings";
import { createEventSource } from "./listener-utils";
export const nekoPositions = writable<NekoPositionsDto>({});
export const { start: startNekoPositions, stop: stopNekoPositions } =
createEventSource(async (addEventListener) => {
nekoPositions.set(await commands.getNekoPositions());
addEventListener(
await events.nekoPositionsUpdated.listen((event) => {
nekoPositions.set(event.payload);
}),
);
});

View File

@@ -8,6 +8,12 @@ export const commands = {
async getAppData() : Promise<UserData> {
return await TAURI_INVOKE("get_app_data");
},
async getAppState() : Promise<AppState> {
return await TAURI_INVOKE("get_app_state");
},
async getNekoPositions() : Promise<NekoPositionsDto> {
return await TAURI_INVOKE("get_neko_positions");
},
async getActiveDollSpriteBase64() : Promise<string | null> {
return await TAURI_INVOKE("get_active_doll_sprite_base64");
},
@@ -118,6 +124,15 @@ async sendInteractionCmd(dto: SendInteractionDto) : Promise<null> {
},
async getModules() : Promise<ModuleMetadata[]> {
return await TAURI_INVOKE("get_modules");
},
async setSceneSetupNekosPosition(nekosPosition: NekoPosition | null) : Promise<void> {
await TAURI_INVOKE("set_scene_setup_nekos_position", { nekosPosition });
},
async setSceneSetupNekosOpacity(nekosOpacity: number) : Promise<void> {
await TAURI_INVOKE("set_scene_setup_nekos_opacity", { nekosOpacity });
},
async setSceneSetupNekosScale(nekosScale: number) : Promise<void> {
await TAURI_INVOKE("set_scene_setup_nekos_scale", { nekosScale });
}
}
@@ -127,13 +142,12 @@ async getModules() : Promise<ModuleMetadata[]> {
export const events = __makeEvents__<{
activeDollSpriteChanged: ActiveDollSpriteChanged,
appDataRefreshed: AppDataRefreshed,
appStateChanged: AppStateChanged,
authFlowUpdated: AuthFlowUpdated,
createDoll: CreateDoll,
cursorMoved: CursorMoved,
editDoll: EditDoll,
friendActiveDollChanged: FriendActiveDollChanged,
friendActiveDollSpritesUpdated: FriendActiveDollSpritesUpdated,
friendCursorPositionsUpdated: FriendCursorPositionsUpdated,
friendDisconnected: FriendDisconnected,
friendRequestAccepted: FriendRequestAccepted,
friendRequestDenied: FriendRequestDenied,
@@ -141,6 +155,7 @@ friendRequestReceived: FriendRequestReceived,
friendUserStatusChanged: FriendUserStatusChanged,
interactionDeliveryFailed: InteractionDeliveryFailed,
interactionReceived: InteractionReceived,
nekoPositionsUpdated: NekoPositionsUpdated,
sceneInteractiveChanged: SceneInteractiveChanged,
setInteractionOverlay: SetInteractionOverlay,
unfriended: Unfriended,
@@ -148,13 +163,12 @@ userStatusChanged: UserStatusChanged
}>({
activeDollSpriteChanged: "active-doll-sprite-changed",
appDataRefreshed: "app-data-refreshed",
appStateChanged: "app-state-changed",
authFlowUpdated: "auth-flow-updated",
createDoll: "create-doll",
cursorMoved: "cursor-moved",
editDoll: "edit-doll",
friendActiveDollChanged: "friend-active-doll-changed",
friendActiveDollSpritesUpdated: "friend-active-doll-sprites-updated",
friendCursorPositionsUpdated: "friend-cursor-positions-updated",
friendDisconnected: "friend-disconnected",
friendRequestAccepted: "friend-request-accepted",
friendRequestDenied: "friend-request-denied",
@@ -162,6 +176,7 @@ friendRequestReceived: "friend-request-received",
friendUserStatusChanged: "friend-user-status-changed",
interactionDeliveryFailed: "interaction-delivery-failed",
interactionReceived: "interaction-received",
nekoPositionsUpdated: "neko-positions-updated",
sceneInteractiveChanged: "scene-interactive-changed",
setInteractionOverlay: "set-interaction-overlay",
unfriended: "unfriended",
@@ -180,12 +195,13 @@ export type AcceleratorModifier = "cmd" | "alt" | "ctrl" | "shift"
export type ActiveDollSpriteChanged = string | null
export type AppConfig = { api_base_url: string | null; debug_mode: boolean; accelerators?: Partial<{ [key in AcceleratorAction]: KeyboardAccelerator }> }
export type AppDataRefreshed = UserData
export type AppState = { sceneSetup: SceneSetup }
export type AppStateChanged = AppState
export type AuthFlowStatus = "started" | "succeeded" | "failed" | "cancelled"
export type AuthFlowUpdated = AuthFlowUpdatedPayload
export type AuthFlowUpdatedPayload = { provider: string; status: AuthFlowStatus; message: string | null }
export type CreateDoll = null
export type CreateDollDto = { name: string; configuration: DollConfigurationDto | null }
export type CursorMoved = CursorPositions
export type CursorPosition = { x: number; y: number }
export type CursorPositions = { raw: CursorPosition; mapped: CursorPosition }
export type DisplayData = { screen_width: number; screen_height: number; monitor_scale_factor: number }
@@ -197,8 +213,6 @@ export type FriendActiveDollChanged = FriendActiveDollChangedPayload
export type FriendActiveDollChangedPayload = { friendId: string; doll: DollDto | null }
export type FriendActiveDollSpritesDto = Partial<{ [key in string]: string }>
export type FriendActiveDollSpritesUpdated = FriendActiveDollSpritesDto
export type FriendCursorPositionsDto = Partial<{ [key in string]: CursorPositions }>
export type FriendCursorPositionsUpdated = FriendCursorPositionsDto
export type FriendDisconnected = FriendDisconnectedPayload
export type FriendDisconnectedPayload = { userId: string }
export type FriendRequestAccepted = FriendRequestAcceptedPayload
@@ -217,9 +231,14 @@ export type InteractionPayloadDto = { senderUserId: string; senderName: string;
export type InteractionReceived = InteractionPayloadDto
export type KeyboardAccelerator = { modifiers?: AcceleratorModifier[]; key?: AcceleratorKey | null }
export type ModuleMetadata = { id: string; name: string; version: string; description: string | null }
export type NekoPosition = "top-left" | "top" | "top-right" | "left" | "right" | "bottom-left" | "bottom" | "bottom-right"
export type NekoPositionDto = { userId: string; isSelf: boolean; cursor: CursorPositions; target: CursorPosition; overrideApplied: boolean }
export type NekoPositionsDto = Partial<{ [key in string]: NekoPositionDto }>
export type NekoPositionsUpdated = NekoPositionsDto
export type PresenceStatus = { title: string | null; subtitle: string | null; graphicsB64: string | null }
export type SceneData = { display: DisplayData; grid_size: number }
export type SceneInteractiveChanged = boolean
export type SceneSetup = { nekosPosition: NekoPosition | null; nekosOpacity: number; nekosScale: number }
export type SendFriendRequestDto = { receiverId: string }
export type SendInteractionDto = { recipientUserId: string; content: string; type: string }
export type SetInteractionOverlay = boolean

View File

@@ -1,11 +1,10 @@
<script>
import { browser } from "$app/environment";
import { onMount, onDestroy } from "svelte";
import { startCursorTracking, stopCursorTracking } from "../events/cursor";
import {
startFriendCursorTracking,
stopFriendCursorTracking,
} from "../events/friend-cursor";
startNekoPositions,
stopNekoPositions,
} from "../events/neko-positions";
import {
startActiveDollSprite,
stopActiveDollSprite,
@@ -15,6 +14,7 @@
stopFriendActiveDollSprite,
} from "../events/friend-active-doll-sprite";
import { startAppData } from "../events/app-data";
import { startAppState, stopAppState } from "../events/app-state";
import { startInteraction, stopInteraction } from "../events/interaction";
import {
startSceneInteractive,
@@ -27,10 +27,10 @@
onMount(async () => {
try {
await startAppData();
await startAppState();
await startActiveDollSprite();
await startFriendActiveDollSprite();
await startCursorTracking();
await startFriendCursorTracking();
await startNekoPositions();
await startSceneInteractive();
await startInteraction();
await startUserStatus();
@@ -40,13 +40,17 @@
});
onDestroy(() => {
stopCursorTracking();
stopFriendCursorTracking();
stopNekoPositions();
stopActiveDollSprite();
stopFriendActiveDollSprite();
stopSceneInteractive();
stopInteraction();
stopUserStatus();
stopAppState();
});
document.addEventListener("contextmenu", (e) => {
e.preventDefault();
});
}
</script>

View File

@@ -9,14 +9,22 @@
import Users from "../../assets/icons/users.svelte";
import Settings from "../../assets/icons/settings.svelte";
import Blocks from "../../assets/icons/blocks.svelte";
import Image from "../../assets/icons/image.svelte";
import Scene from "./tabs/scene/scene.svelte";
import { getVersion } from "@tauri-apps/api/app";
let showInteractionOverlay = false;
let appVersion = "";
onMount(() => {
const unlisten = events.setInteractionOverlay.listen((event) => {
showInteractionOverlay = event.payload as boolean;
});
getVersion().then((version) => {
appVersion = version;
});
return () => {
unlisten.then((u) => u());
};
@@ -40,15 +48,28 @@
></div>
{/if}
<div class="flex flex-col gap-2 h-full max-h-full">
<div class="size-full flex flex-col max-h-full gap-2 h-full">
<div class="size-full flex flex-col max-h-full gap-2 h-full relative">
<div class="absolute top-2 right-2">
<p class="text-xs font-mono opacity-50">v{appVersion}</p>
</div>
<div class="tabs tabs-lift h-full flex-1">
<label class="tab">
<input
type="radio"
name="app_menu_tabs"
aria-label="Your Nekos"
aria-label="Scene Configuration"
checked
/>
<div class="*:size-4">
<Image />
</div>
</label>
<div class="tab-content bg-base-100 border-base-300 p-4">
<Scene />
</div>
<label class="tab">
<input type="radio" name="app_menu_tabs" aria-label="Your Nekos" />
<div class="*:size-4">
<PawPrint />
</div>

View File

@@ -6,6 +6,8 @@
import type { DollColorSchemeDto } from "$lib/bindings";
export let dollColorScheme: DollColorSchemeDto;
export let spriteScale = 2;
export let spriteOpacity = 1;
let previewBase64: string | null = null;
let error: string | null = null;
@@ -80,7 +82,10 @@
});
</script>
<div class="scale-200 p-4">
<div
style="transform: scale({spriteScale}); padding: {spriteScale *
10}px; opacity: {spriteOpacity};"
>
<div class="size-8">
{#if error}
<div

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { commands } from "$lib/bindings";
import { appState, type NeksPosition } from "../../../../events/app-state";
const positions: { value: NeksPosition | null; label: string }[] = [
{ value: "top-left", label: "Top Left" },
{ value: "top", label: "Top" },
{ value: "top-right", label: "Top Right" },
{ value: "left", label: "Left" },
{ value: null, label: "" },
{ value: "right", label: "Right" },
{ value: "bottom-left", label: "Bottom Left" },
{ value: "bottom", label: "Bottom" },
{ value: "bottom-right", label: "Bottom Right" },
];
async function selectPosition(position: NeksPosition | null) {
await commands.setSceneSetupNekosPosition(
$appState.sceneSetup.nekosPosition === position ? null : position,
);
}
let selectedLabel = $derived(
positions.find((p) => p.value === $appState.sceneSetup.nekosPosition)
?.label ?? "",
);
</script>
<div class="collapse bg-base-100 border-base-300 border">
<input type="checkbox" checked />
<div class="collapse-title py-2 text-sm opacity-70">Neko Reposition</div>
<div class="collapse-content">
<div class="flex flex-row gap-4 h-full pt-4 border-t border-base-300">
<div class="h-full flex flex-col justify-between">
<div>
<p class="text-sm opacity-50">
Choose a corner to gather nekos into a cluster
</p>
</div>
<div>
<input
type="checkbox"
checked={$appState.sceneSetup.nekosPosition !== null}
onclick={() =>
selectPosition(
$appState.sceneSetup.nekosPosition ? null : "bottom-left",
)}
class="toggle toggle-xl {$appState.sceneSetup.nekosPosition
? 'bg-primary/10 toggle-primary'
: 'bg-base-200'}"
/>
</div>
</div>
<div class="card bg-base-200/50 p-1 w-max border border-base-300">
<div class="grid grid-cols-3 gap-6 items-center w-max">
{#each positions as pos}
{#if pos.value === null}
<div></div>
{:else}
<button
class={"btn-xs btn btn-square " +
($appState.sceneSetup.nekosPosition === pos.value
? "btn-primary"
: "")}
aria-label={pos.label}
onclick={() => selectPosition(pos.value)}
></button>
{/if}
{/each}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import { commands } from "$lib/bindings";
import { appState } from "../../../../events/app-state";
import DollPreview from "../../components/doll-preview.svelte";
async function updateOpacity(value: number) {
await commands.setSceneSetupNekosOpacity(value);
}
async function updateScale(value: number) {
await commands.setSceneSetupNekosScale(value);
}
</script>
<div class="collapse bg-base-100 border-base-300 border">
<input type="checkbox" checked />
<div class="collapse-title py-2 text-sm opacity-70">Neko View</div>
<div class="collapse-content">
<div class="pt-4 border-t border-base-300">
<div class="flex flex-row gap-4">
<div
class="border border-primary relative shadow-[inset_0_0_8px] bg-primary/5 shadow-primary w-40 card"
>
<div class="size-full absolute bg-gridded opacity-25"></div>
<div class="size-full absolute">
<div
class="flex flex-row size-full items-end justify-between text-[8px] text-primary p-1 font-mono"
>
<div class="text-start flex flex-col">
<p>Scale</p>
<p>Opacity</p>
</div>
<div class="text-end flex flex-col">
<p>{($appState.sceneSetup.nekosScale * 100).toFixed(0)}%</p>
<p>{($appState.sceneSetup.nekosOpacity * 100).toFixed(0)}%</p>
</div>
</div>
</div>
<div
class="size-full flex flex-row -translate-y-2 justify-center items-center"
>
<DollPreview
dollColorScheme={{ body: "b7f2ff", outline: "496065" }}
spriteScale={$appState.sceneSetup.nekosScale}
spriteOpacity={$appState.sceneSetup.nekosOpacity}
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-2">
<p class="text-xs opacity-70">Opacity</p>
<div class="flex flex-row gap-2 items-center">
<input
type="range"
class="range flex-1"
min="0.1"
max="1"
step="0.01"
value={$appState.sceneSetup.nekosOpacity}
oninput={(event) =>
updateOpacity(
Number((event.currentTarget as HTMLInputElement).value),
)}
/>
</div>
</div>
<div class="flex flex-col gap-2">
<p class="text-xs opacity-70">Scale</p>
<div class="flex flex-row gap-2 items-center">
<input
type="range"
class="range flex-1"
min="0.5"
max="2"
step="0.25"
value={$appState.sceneSetup.nekosScale}
oninput={(event) =>
updateScale(
Number((event.currentTarget as HTMLInputElement).value),
)}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.bg-gridded {
background-image:
linear-gradient(
0deg,
transparent 24%,
var(--color-primary) 25%,
var(--color-primary) 26%,
transparent 27%,
transparent 74%,
var(--color-primary) 75%,
var(--color-primary) 76%,
transparent 77%,
transparent
),
linear-gradient(
90deg,
transparent 24%,
var(--color-primary) 25%,
var(--color-primary) 26%,
transparent 27%,
transparent 74%,
var(--color-primary) 75%,
var(--color-primary) 76%,
transparent 77%,
transparent
);
background-size: 32px 32px;
}
</style>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import NekoView from "./neko-view.svelte";
import NekoReposition from "./neko-reposition.svelte";
</script>
<div class="flex flex-col gap-4 w-full h-full">
<p class="text-lg font-bold">Scene Configuration</p>
<NekoView />
<NekoReposition />
</div>

View File

@@ -1,9 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import {
commands,
type AppConfig,
} from "$lib/bindings";
import { commands, type AppConfig } from "$lib/bindings";
let form: AppConfig = {
api_base_url: "",
@@ -109,7 +106,6 @@
bind:checked={form.debug_mode}
/>
</label>
</div>
{#if errorMessage}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { cursorPositionOnScreen } from "../../events/cursor";
import { friendsCursorPositions } from "../../events/friend-cursor";
import { nekoPositions } from "../../events/neko-positions";
import { appData } from "../../events/app-data";
import { activeDollSpriteUrl } from "../../events/active-doll-sprite";
import { friendActiveDollSpriteUrls } from "../../events/friend-active-doll-sprite";
@@ -10,15 +9,16 @@
friendsPresenceStates,
currentPresenceState,
} from "../../events/user-status";
import { commands } from "$lib/bindings";
import { commands, type NekoPositionDto } from "$lib/bindings";
import DebugBar from "./components/debug-bar.svelte";
import Neko from "./components/neko/neko.svelte";
import PetMenu from "./components/pet-menu/pet-menu.svelte";
import PetMessagePop from "./components/pet-message-pop.svelte";
import PetMessageSend from "./components/pet-message-send.svelte";
import type { UserBasicDto } from "$lib/bindings";
import { appState } from "../../events/app-state";
let debugMode = false;
let debugMode = $state(false);
onMount(async () => {
const config = await commands.getClientConfig();
@@ -31,6 +31,12 @@
?.friend ?? undefined
);
}
let nekoEntries = $derived.by(() => {
return Object.entries($nekoPositions).filter(
(entry): entry is [string, NekoPositionDto] => entry[1] !== undefined,
);
});
</script>
<div class="w-svw h-svh p-4 relative overflow-hidden">
@@ -41,26 +47,26 @@
await commands.setSceneInteractive(false, true);
}}>&nbsp;</button
>
{#if $appData?.user?.activeDollId}
{#each nekoEntries as [userId, position] (userId)}
{@const spriteUrl = position.isSelf
? $activeDollSpriteUrl
: $friendActiveDollSpriteUrls[userId]}
{#if spriteUrl}
{@const friend = position.isSelf ? undefined : getFriend(userId)}
<Neko
targetX={$cursorPositionOnScreen.raw.x}
targetY={$cursorPositionOnScreen.raw.y}
spriteUrl={$activeDollSpriteUrl}
/>
{/if}
{#each Object.entries($friendsCursorPositions) as [friendId, position] (friendId)}
{#if $friendActiveDollSpriteUrls[friendId]}
{@const friend = getFriend(friendId)}
<Neko
targetX={position.raw.x}
targetY={position.raw.y}
spriteUrl={$friendActiveDollSpriteUrls[friendId]}
initialX={position.raw.x}
initialY={position.raw.y}
targetX={position.target.x}
targetY={position.target.y}
spriteUrl={spriteUrl}
initialX={position.target.x}
initialY={position.target.y}
scale={$appState.sceneSetup.nekosScale}
opacity={$appState.sceneSetup.nekosOpacity}
>
<PetMenu user={friend!} ariaLabel={`Open ${friend?.name} actions`} />
<PetMessagePop userId={friendId} />
<PetMessageSend userId={friendId} userName={friend?.name ?? "Friend"} />
{#if !position.isSelf && friend}
<PetMenu user={friend} ariaLabel={`Open ${friend.name} actions`} />
<PetMessagePop userId={userId} />
<PetMessageSend userId={userId} userName={friend.name} />
{/if}
</Neko>
{/if}
{/each}
@@ -68,9 +74,8 @@
<div id="debug-bar">
<DebugBar
isInteractive={$sceneInteractive}
cursorPosition={$cursorPositionOnScreen}
nekoPositions={$nekoPositions}
presenceStatus={$currentPresenceState?.presenceStatus ?? null}
friendsCursorPositions={$friendsCursorPositions}
friends={$appData?.friends ?? []}
friendsPresenceStates={$friendsPresenceStates}
/>

View File

@@ -1,5 +1,10 @@
<script lang="ts">
import type { PresenceStatus, UserStatusPayload } from "$lib/bindings";
import type {
NekoPositionDto,
NekoPositionsDto,
PresenceStatus,
UserStatusPayload,
} from "$lib/bindings";
interface Friend {
friend?: {
@@ -10,22 +15,32 @@
interface Props {
isInteractive: boolean;
cursorPosition: { mapped: { x: number; y: number } };
nekoPositions: NekoPositionsDto;
presenceStatus: PresenceStatus | null;
friendsCursorPositions: Record<string, { mapped: { x: number; y: number } }>;
friends: Friend[];
friendsPresenceStates: Record<string, UserStatusPayload>;
}
let {
isInteractive,
cursorPosition,
nekoPositions,
presenceStatus,
friendsCursorPositions,
friends,
friendsPresenceStates,
}: Props = $props();
let selfCursor = $derived(
Object.values(nekoPositions).find((position) => position?.isSelf)?.cursor,
);
let friendEntries = $derived.by(() => {
return Object.entries(nekoPositions).filter(
(entry): entry is [string, NekoPositionDto] => {
return entry[1] !== undefined && !entry[1].isSelf;
},
);
});
function getFriendById(userId: string) {
const friend = friends.find((f) => f.friend?.id === userId);
return friend?.friend;
@@ -49,9 +64,11 @@
</span>
</div>
{#if selfCursor}
<span class="font-mono text-xs badge py-3">
{cursorPosition.mapped.x.toFixed(3)}, {cursorPosition.mapped.y.toFixed(3)}
{selfCursor.mapped.x.toFixed(3)}, {selfCursor.mapped.y.toFixed(3)}
</span>
{/if}
{#if presenceStatus}
<span class="font-mono text-xs badge py-3 flex items-center gap-2">
@@ -66,16 +83,17 @@
</span>
{/if}
{#if Object.keys(friendsCursorPositions).length > 0}
{#if friendEntries.length > 0}
<div class="flex flex-col gap-2">
<div>
{#each Object.entries(friendsCursorPositions) as [userId, position]}
{#each friendEntries as [userId, position]}
{@const status = getFriendStatus(userId)}
<div class="badge py-3 text-xs text-left flex flex-row gap-2">
<span class="font-bold">{getFriendById(userId)?.name}</span>
<div class="flex flex-row font-mono gap-2">
<span>
{position.mapped.x.toFixed(3)}, {position.mapped.y.toFixed(3)}
{position.cursor.mapped.x.toFixed(3)},
{position.cursor.mapped.y.toFixed(3)}
</span>
{#if status}
<span class="flex items-center gap-1">

View File

@@ -4,6 +4,7 @@
import { setSprite } from "./sprites";
import { calculateDirection, moveTowards, clampPosition } from "./physics";
import { updateIdle } from "./idle";
import { appState } from "../../../../events/app-state";
interface Props {
targetX: number;
@@ -11,6 +12,8 @@
spriteUrl: string;
initialX?: number;
initialY?: number;
scale?: number;
opacity?: number;
children?: Snippet;
}
@@ -20,10 +23,13 @@
spriteUrl,
initialX = 32,
initialY = 32,
scale = 1.0,
opacity = 1.0,
children,
}: Props = $props();
let nekoEl: HTMLDivElement;
let wrapperEl: HTMLDivElement;
let animationFrameId: number;
let nekoPos = $state({ x: initialX, y: initialY });
@@ -86,11 +92,15 @@
const newPos = moveTowards(nekoPos.x, nekoPos.y, targetPos.x, targetPos.y);
nekoPos = newPos;
nekoEl.style.transform = `translate(${nekoPos.x - 16}px, ${nekoPos.y - 16}px)`;
nekoEl.style.transform = `scale(${scale ?? 1.0})`;
nekoEl.style.opacity = `${opacity ?? 1.0}`;
wrapperEl.style.transform = `translate(${nekoPos.x - 16}px, ${nekoPos.y - 16}px)`;
}
onMount(() => {
nekoEl.style.backgroundImage = `url(${spriteUrl})`;
nekoEl.style.opacity = `${opacity ?? 1.0}`;
wrapperEl.style.transform = `translate(${nekoPos.x - 16}px, ${nekoPos.y - 16}px)`;
animationFrameId = requestAnimationFrame(frame);
});
@@ -101,18 +111,27 @@
});
$effect(() => {
if (nekoEl && spriteUrl) {
if (nekoEl && spriteUrl && $appState) {
nekoEl.style.transform = `scale(${scale ?? 1.0})`;
nekoEl.style.backgroundImage = `url(${spriteUrl})`;
nekoEl.style.opacity = `${opacity ?? 1.0}`;
}
});
</script>
<div
bind:this={nekoEl}
bind:this={wrapperEl}
class="pointer-events-none fixed z-999 size-8 select-none"
style="width: 32px; height: 32px; position: fixed; image-rendering: pixelated;"
style="position: fixed; width: 32px; height: 32px;"
>
<div class="relative size-full">
<div class="relative">
<div
bind:this={nekoEl}
class="size-8"
style="position: absolute; image-rendering: pixelated;"
></div>
<div class="absolute size-8">
{@render children?.()}
</div>
</div>
</div>