Skip to content

Commit 0da8d77

Browse files
committed
Working commit, basic sync enabled
1 parent 9340f01 commit 0da8d77

File tree

15 files changed

+377
-31
lines changed

15 files changed

+377
-31
lines changed

.cargo/config.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[build]
2-
# target = "wasm32-unknown-unknown"
3-
target = "aarch64-apple-darwin"
2+
target = "wasm32-unknown-unknown"
3+
# target = "aarch64-apple-darwin"

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pp_draw = { path = "crates/pp_draw", version = "0.0.1" }
1818
pp_editor = { path = "crates/pp_editor", version = "0.0.1" }
1919
pp_export = { path = "crates/pp_export", version = "0.0.1" }
2020
pp_import = { path = "crates/pp_import", version = "0.0.1" }
21+
pp_protocol = { path = "crates/pp_protocol", version = "0.0.1" }
2122
pp_save = { path = "crates/pp_save", version = "0.0.1" }
2223
pp_server = { path = "crates/pp_server", version = "0.0.1" }
2324

crates/pp_client/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pp_save.workspace = true
1818
pp_core.workspace = true
1919
pp_draw.workspace = true
2020
pp_editor.workspace = true
21+
pp_protocol.workspace = true
2122
# External:
2223
bitflags.workspace = true
2324
web-sys = { version = "0.3", features = [
@@ -26,6 +27,11 @@ web-sys = { version = "0.3", features = [
2627
"console",
2728
"Blob",
2829
"Url",
30+
"WebSocket",
31+
"MessageEvent",
32+
"ErrorEvent",
33+
"CloseEvent",
34+
"BinaryType",
2935
] }
3036
cfg-if.workspace = true
3137
log.workspace = true
@@ -36,6 +42,7 @@ console_error_panic_hook = { workspace = true }
3642
console_log.workspace = true
3743
cgmath.workspace = true
3844
serde.workspace = true
45+
serde_json.workspace = true
3946
serde-wasm-bindgen.workspace = true
4047
slotmap.workspace = true
4148
tsify.workspace = true
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use std::{cell::RefCell, rc::Rc};
2+
3+
use pp_core::{Command, CommandError, CommandType, RedoError, State, UndoError};
4+
use wasm_bindgen::JsValue;
5+
6+
use crate::command::sync::SyncConnectionConfig;
7+
8+
pub mod sync;
9+
10+
pub struct MultiplayerSyncConfig<'a> {
11+
server_url: &'a str,
12+
}
13+
14+
#[derive(Debug, Default)]
15+
pub struct MultiplayerCommandStack {
16+
commands: pp_core::CommandStack,
17+
/// Optional configuration for a persistence module
18+
sync: Option<sync::SyncManager>,
19+
}
20+
21+
impl MultiplayerCommandStack {
22+
/// Rolls back the latest command on the undo/redo stack
23+
pub fn undo(&mut self, state: &mut State) -> Result<(), UndoError> {
24+
if let Some(sync) = &self.sync {
25+
let command_i =
26+
self.commands.stack.len().wrapping_sub(self.commands.redos_available + 1);
27+
let Some(command) = self.commands.stack.get(command_i) else {
28+
return Err(UndoError::NoMoreUndos);
29+
};
30+
let _ = sync.send_command(command, true);
31+
};
32+
self.commands.undo(state)
33+
}
34+
35+
/// Redoes the latest undone command on the undo/redo stack
36+
pub fn redo(&mut self, state: &mut State) -> Result<(), RedoError> {
37+
if let Some(sync) = &self.sync {
38+
let command_i = self.commands.stack.len().wrapping_sub(self.commands.redos_available);
39+
let Some(command) = self.commands.stack.get(command_i) else {
40+
return Err(RedoError::NoMoreRedos);
41+
};
42+
let _ = sync.send_command(command, false);
43+
};
44+
self.commands.redo(state)
45+
}
46+
47+
/// Adds a new undoable command onto the undo / redo stack. This should be
48+
/// consistent with any corresponding modifications that happened on the mesh.
49+
pub fn add(&mut self, command: CommandType) {
50+
self.sync.as_mut().inspect(|sync| {
51+
let _ = sync.send_command(&command, false);
52+
});
53+
self.commands.add(command);
54+
}
55+
56+
/// Executes the command against the state and then adds the command onto
57+
/// the undo / redo stack. If you don't want to execute the command, just
58+
/// use `add`.
59+
pub fn execute(&mut self, state: &mut State, command: CommandType) -> Result<(), CommandError> {
60+
command.execute(state)?;
61+
self.add(command);
62+
Ok(())
63+
}
64+
65+
// ---- MULTIPLAYER SYNC ----
66+
67+
/// Connect to a multiplayer server for real-time synchronization
68+
pub fn subscribe(
69+
&mut self,
70+
state: Rc<RefCell<State>>,
71+
config: &SyncConnectionConfig,
72+
) -> Result<(), JsValue> {
73+
let sync = sync::SyncManager::connect(state, config)?;
74+
self.sync = Some(sync);
75+
log::info!("Connected to multiplayer server");
76+
Ok(())
77+
}
78+
79+
/// Disconnect from the multiplayer server
80+
pub fn unsubscribe(&mut self) -> Result<(), JsValue> {
81+
if let Some(sync) = self.sync.take() {
82+
sync.close()?;
83+
log::info!("Disconnected from multiplayer server");
84+
}
85+
Ok(())
86+
}
87+
88+
/// Check if connected to a multiplayer server
89+
pub fn is_subscribed(&self) -> bool {
90+
self.sync.as_ref().map_or(false, |s| s.is_connected())
91+
}
92+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
use pp_core::{Command, CommandType, State};
2+
use pp_protocol::{ClientMessage, ServerMessage};
3+
use pp_save::{load::Loadable, SaveFile};
4+
use std::{cell::RefCell, io::Cursor, rc::Rc};
5+
use wasm_bindgen::{prelude::*, JsCast};
6+
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
7+
8+
#[wasm_bindgen]
9+
pub struct SyncConnectionConfig {
10+
/// The hostname of the server
11+
server_url: String,
12+
/// The document ID on the server
13+
doc_id: String,
14+
}
15+
16+
#[wasm_bindgen]
17+
impl SyncConnectionConfig {
18+
#[wasm_bindgen(constructor)]
19+
pub fn new(server_url: String, doc_id: String) -> Self {
20+
Self { server_url, doc_id }
21+
}
22+
}
23+
24+
/// Manages WebSocket connection to the pp_server for real-time sync
25+
#[derive(Debug)]
26+
pub struct SyncManager {
27+
ws: WebSocket,
28+
}
29+
30+
impl SyncManager {
31+
/// Connect to a pp_server WebSocket endpoint for a specific document
32+
pub fn connect(
33+
state: Rc<RefCell<State>>,
34+
config: &SyncConnectionConfig,
35+
) -> Result<Self, JsValue> {
36+
let ws_url = format!("{}/documents/{}", config.server_url, config.doc_id);
37+
log::info!("Connecting to WebSocket: {}", ws_url);
38+
39+
let ws = WebSocket::new(&ws_url)?;
40+
41+
// Clone references for closures
42+
let state_clone = Rc::clone(&state);
43+
let doc_id_clone = config.doc_id.clone();
44+
45+
// Handle incoming messages from server
46+
let on_message = Closure::wrap(Box::new(move |e: MessageEvent| {
47+
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
48+
let text: String = text.into();
49+
match serde_json::from_str::<ServerMessage>(&text) {
50+
Ok(ServerMessage::Joined {
51+
state: state_bytes, version, client_count, ..
52+
}) => {
53+
log::info!(
54+
"Joined document (version: {}, clients: {})",
55+
version,
56+
client_count
57+
);
58+
// Load initial state from server
59+
if let Ok(save_file) = SaveFile::from_reader(Cursor::new(state_bytes)) {
60+
if let Ok(loaded_state) = State::load(save_file) {
61+
state_clone.replace(loaded_state);
62+
log::info!("Loaded initial state from server");
63+
}
64+
}
65+
}
66+
Ok(ServerMessage::Command { client_id, command, rollback, .. }) => {
67+
log::info!("Received command from {}: {:?}", client_id, command);
68+
// Apply command from another client
69+
let mut state = state_clone.borrow_mut();
70+
if let Err(e) = match rollback {
71+
true => command.rollback(&mut state),
72+
false => command.execute(&mut state),
73+
} {
74+
log::error!("Failed to apply remote command: {:?}", e);
75+
}
76+
}
77+
Ok(ServerMessage::StateSync { state, version }) => {
78+
log::info!("Received state sync (version: {})", version);
79+
if let Ok(save_file) = SaveFile::from_reader(Cursor::new(state)) {
80+
if let Ok(loaded_state) = State::load(save_file) {
81+
state_clone.replace(loaded_state);
82+
}
83+
}
84+
}
85+
Ok(ServerMessage::ClientJoined { client_id, client_count }) => {
86+
log::info!("Client {} joined ({} total)", client_id, client_count);
87+
}
88+
Ok(ServerMessage::ClientLeft { client_id, client_count }) => {
89+
log::info!("Client {} left ({} remaining)", client_id, client_count);
90+
}
91+
Err(e) => {
92+
log::error!("Failed to parse server message: {:?}", e);
93+
}
94+
}
95+
}
96+
}) as Box<dyn FnMut(MessageEvent)>);
97+
98+
let on_error = Closure::wrap(Box::new(move |e: ErrorEvent| {
99+
log::error!("WebSocket error: {:?}", e);
100+
}) as Box<dyn FnMut(ErrorEvent)>);
101+
102+
let on_close = Closure::wrap(Box::new(move |e: CloseEvent| {
103+
log::info!("WebSocket closed: code={}, reason={}", e.code(), e.reason());
104+
}) as Box<dyn FnMut(CloseEvent)>);
105+
106+
// Set event handlers
107+
ws.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
108+
ws.set_onerror(Some(on_error.as_ref().unchecked_ref()));
109+
ws.set_onclose(Some(on_close.as_ref().unchecked_ref()));
110+
// Keep all the closures alive
111+
on_message.forget();
112+
on_error.forget();
113+
on_close.forget();
114+
115+
// Set up onopen to send Join message
116+
let ws_clone = ws.clone();
117+
let on_open = Closure::once(Box::new(move || {
118+
log::info!("WebSocket connected, sending Join message");
119+
let join_msg = ClientMessage::Join { doc_id: doc_id_clone };
120+
if let Ok(json) = serde_json::to_string(&join_msg) {
121+
let _ = ws_clone.send_with_str(&json);
122+
}
123+
}) as Box<dyn FnOnce()>);
124+
125+
ws.set_onopen(Some(on_open.as_ref().unchecked_ref()));
126+
on_open.forget(); // Keep the closure alive
127+
128+
Ok(Self { ws })
129+
}
130+
131+
/// Send a command to the server
132+
pub fn send_command(&self, command: &CommandType, rollback: bool) -> Result<(), JsValue> {
133+
// log::info!("{:?}", command);
134+
let msg = ClientMessage::Command { command: command.clone(), rollback };
135+
let json = serde_json::to_string(&msg).map_err(|e| {
136+
log::error!("{:?}", e);
137+
JsValue::from_str(&format!("Failed to serialize command: {:?}", e))
138+
})?;
139+
self.ws.send_with_str(&json)?;
140+
Ok(())
141+
}
142+
143+
/// Check if the WebSocket is currently connected
144+
pub fn is_connected(&self) -> bool {
145+
self.ws.ready_state() == WebSocket::OPEN
146+
}
147+
148+
/// Close the WebSocket connection
149+
pub fn close(&self) -> Result<(), JsValue> {
150+
self.ws.close()
151+
}
152+
}
153+
154+
impl Drop for SyncManager {
155+
fn drop(&mut self) {
156+
let _ = self.ws.close();
157+
}
158+
}

crates/pp_client/src/event.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use pp_editor::tool;
33
use std::{cell::RefCell, rc::Rc};
44
use wasm_bindgen::prelude::*;
55

6-
use crate::keyboard;
6+
use crate::{command, keyboard};
77

88
/// Whether or not a button is pressed.
99
#[wasm_bindgen]
@@ -79,10 +79,10 @@ impl core::fmt::Display for ExternalEventHandleError {
7979

8080
/// A common event context making core state objects available inside of event
8181
/// handlers, including the state of any modifiers.
82-
#[derive(Debug, Default, Clone)]
82+
#[derive(Debug, Default)]
8383
pub(crate) struct EventContext {
8484
pub(crate) state: Rc<RefCell<pp_core::State>>,
85-
pub(crate) history: Rc<RefCell<pp_core::CommandStack>>,
85+
pub(crate) history: Rc<RefCell<command::MultiplayerCommandStack>>,
8686
pub(crate) renderer: Rc<RefCell<Option<pp_draw::Renderer<'static>>>>,
8787
pub(crate) modifiers: keyboard::ModifierKeys,
8888
pub(crate) surface_dpi: f32,

0 commit comments

Comments
 (0)