some tokens stuff (ye i started to work on the map, also i 'fixed' the context menu for msgs but still needs to be impl-ed)

This commit is contained in:
Rusty Striker 2024-10-08 19:55:48 +03:00
parent 97475599a7
commit be6dd7c0e4
Signed by: RustyStriker
GPG key ID: 87E4D691632DFF15
13 changed files with 296 additions and 76 deletions

1
Cargo.lock generated
View file

@ -1031,6 +1031,7 @@ name = "open_tavern"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"futures-util",
"parking_lot",
"serde",

View file

@ -12,3 +12,4 @@ sqlx = { version = "0.8.2", features = ["runtime-tokio", "sqlite"] }
tavern_macros = { version = "0.1.0", path = "tavern_macros" }
parking_lot = "0.12.3"
futures-util = "0.3.30"
base64 = "0.22.1"

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View file

@ -4,9 +4,11 @@
<script src="./socket.js"></script>
<script>
// init - game view (map)
const GRID_SIZE = 200; // Grid size in pixels
var mapScale = 1.0;
var mapOffsetX = 0.0;
var mapOffsetY = 0.0;
function init() {
let view = document.getElementById('game-view');
view.onwheel = onGameViewScroll;
@ -35,6 +37,11 @@
}
// focus on the username field for the sake of just pressing enter multiple times
document.getElementById('login-username').focus();
document.body.onclick = (e) => {
document.getElementById('msg-context-menu').style.display = 'none';
}
// TODO: Remove when done dev-ing
tavern.onmessage({ text: 'test', id: 1, source: 'rusty', character: 'bart' });
}
tavern.onlogin = (s) => {
@ -45,6 +52,7 @@
game.style.display = 'flex';
// get last 50 msgs (i think that is enough for now) when we get in
tavern.get_last_msgs(50);
tavern.get_tokens();
}
else {
alert("Invalid username or password!");
@ -57,13 +65,21 @@
// #abusing_style_order_as_both_id_variable_and_forcing_chronological_order
msg.style.order = m.id;
msg.innerHTML = `
<b style='align-self: center;'>${m.source}</b>
<br>
<p style='align-self: center; margin: 4px 0;'>
<b>${m.character ?? ''}</b>
(${m.source})
</p>
<hr style='width: 75%;' />
<p style='margin: 4px 2px;'>
${m.text}
</p>
`
msg.oncontextmenu = () => {
document.getElementById('msg-context-menu').style.display = 'flex';
msg.oncontextmenu = (e) => {
if(e.shiftKey) { return true; }
let cm = document.getElementById('msg-context-menu');
cm.style.display = 'flex';
cm.style.top = `${e.pageY}px`;
cm.style.left = `${e.pageX}px`;
return false;
}
let history = document.getElementById('chat-history');
@ -75,6 +91,26 @@
history.appendChild(msg);
msg.scrollIntoView();
}
tavern.onspawntoken = (t) => {
console.log(t);
let map = document.getElementById('map');
let token = document.createElement('div');
token.className = 'token';
token.style.top = `${t.y * GRID_SIZE}px`;
token.style.left = `${t.x * GRID_SIZE}px`;
token.token_id = t.token_id;
token.innerHTML = `
<img src='data:image/jpg;base64,${t.img}'>
`
map.append(token);
}
tavern.onmovetoken = (m) => {
let token = Array.from(document.getElementsByClassName('token')).filter(t => t.token_id == m.token_id)[0]
if(token) {
token.style.top = `${m.y * GRID_SIZE}px`;
token.style.left = `${m.x * GRID_SIZE}px`;
}
}
function onLoginClick() {
let username = document.getElementById('login-username').value;
let pass = document.getElementById('login-pass').value;
@ -152,6 +188,36 @@
display: flex;
flex-direction: column;
}
#msg-context-menu {
display: none;
position: absolute;
z-index: 1000;
padding: 4px;
}
#msg-context-menu ul {
padding: 0px;
margin: 0px;
list-style: none;
}
#msg-context-menu ul li {
padding: 4px;
}
#msg-context-menu ul li:hover {
background: darkgray;
cursor: pointer;
}
.token {
position: absolute;
transition:
top 0.5s ease-in,
left 0.5s ease-in;
}
.token img {
cursor: grab;
width: 200px;
height: 200px;
}
</style>
</head>
@ -184,13 +250,12 @@
</div>
<div id="map" style="position:absolute;">
<img src="https://rustystriker.dev/molly.jpg" height="200%" >
<img src="https://rustystriker.dev/louise.jpg" height="10%" style="position: absolute; left:20px; top: 20px;" >
</div>
</div>
</div>
<div id="msg-context-menu" class="chat-message" style="display: none; position: absolute; z-index: 1000; top: 0px;">
<div id="msg-context-menu" class="chat-message">
<ul>
<li onclick='document.getElementById("msg-context-menu").style.display="none"'>Edit</li>
<li>Delete (TODO)</li>
</ul>
</div>
</body>

View file

@ -1,5 +1,6 @@
const tavern = {
socket: socket = new WebSocket('ws:/' + window.location.host + '/ws'),
msgs: [],
connected: false,
loggedIn: false,
call: (f, ...args) => {
@ -8,6 +9,27 @@ const tavern = {
}
}
};
tavern.add_msg_to_history = (m) => {
let id = m.id - 1;
if(id >= 0) {
if(id < tavern.msgs.length) {
if(tavern.msgs[id].id == id + 1) {
tavern.msgs[id] = m;
}
else {
for(let i = 0; i < tavern.msgs.length; i += 1) {
if(tavern.msgs[i].id > id) {
tavern.msgs.splice(i, 0, m);
break;
}
}
}
}
else {
tavern.msgs.push(m)
}
}
}
tavern.socket.onopen = () => tavern.connected = true;
tavern.socket.onmessage = (m) => {
@ -18,13 +40,21 @@ tavern.socket.onmessage = (m) => {
tavern.call(tavern.onlogin, tavern.socket.loggedIn);
}
if(m.message) {
tavern.call(tavern.onmessage, m.message)
tavern.add_msg_to_history(m.message);
tavern.call(tavern.onmessage, m.message);
}
if(m.get_chat_history) {
m.get_chat_history.forEach(msg => {
tavern.add_msg_to_history(msg);
tavern.call(tavern.onmessage, msg);
});
}
if(m.spawn_token) {
tavern.call(tavern.onspawntoken, m.spawn_token);
}
if(m.move_token) {
tavern.call(tavern.onmovetoken, m.move_token);
}
}
tavern.login = (username, password) => {
if(!tavern.connected || tavern.loggedIn) { return false; }
@ -32,7 +62,14 @@ tavern.login = (username, password) => {
}
tavern.simple_msg = (msg, token) => {
if(!tavern.connected || tavern.loggedIn) { return false; }
tavern.socket.send(JSON.stringify({ message: { text: msg, source: token ?? "" } }));
tavern.socket.send(JSON.stringify({ message: { text: msg, character: token ?? "" } }));
}
tavern.edit_msg = (new_text, id) => {
if(id <= tavern.msgs.length && id > 0) {
let msg = tavern.msgs[id - 1];
msg.text = new_text;
tavern.socket.send(JSON.stringify({ message: msg }));
}
}
tavern.get_chat_history = (from, amount) => {
if(!tavern.connected || tavern.loggedIn) { return false; }
@ -42,3 +79,11 @@ tavern.get_last_msgs = (amount) => {
if(!tavern.connected || tavern.loggedIn) { return false; }
tavern.socket.send(JSON.stringify({ get_last_messages: { amount: amount } }))
}
tavern.get_tokens = () => {
if(!tavern.connected || tavern.loggedIn) { return false; }
tavern.socket.send(JSON.stringify('get_tokens'));
}
tavern.move_token = (id, x, y) => {
if(!tavern.connected || tavern.loggedIn) { return false; }
tavern.socket.send(JSON.stringify({ move_token: { token_id: id, x: x, y: y } }));
}

View file

@ -15,7 +15,7 @@
[ ] impl different requests
[ ] actual normal login
[ ] allow sending of old info
[x] allow sending of old info
[x] chat history
[ ] send texture (map/token/image)
[ ] force show something

View file

@ -15,7 +15,11 @@ pub enum Request {
Message(ChatMessage),
GetChatHistory { amount: usize, from: usize },
GetLastMessages { amount: usize, },
GetTokens,
SpawnToken { x: i32, y: i32, img_path: String },
MoveToken { token_id: usize, x: i32, y: i32 },
Quit,
Kick(String),
Shutdown
}
#[derive(Serialize, Clone)]
@ -25,6 +29,8 @@ pub enum Response {
Login(login::LoginResult),
Message(ChatMessage),
GetChatHistory(Vec<ChatMessage>),
MoveToken { token_id: usize, x: i32, y: i32 },
SpawnToken { token_id: usize, x: i32, y: i32, img: String },
Quit { id: String },
Shutdown,

View file

@ -1,24 +1,107 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct ChatMessage {
/// message text, `{item}` can be used to refer to items and such, where item is of the path such as `items/sword` or `spells/fireball`
pub text: String,
/// Source user initiated the action/sent the message
#[serde(default = "default_source")]
pub source: String,
/// Character "sending" the message
pub character: Option<String>,
/// whisper item
pub whisper: Option<String>,
/// Rolls of the action, if not empty a roll should happen before
pub roll: Option<Vec<RollDialogOption>>,
/// Optional roll target
pub roll_target: Option<i32>,
/// Optional action buttons, for a chat message this will be empty
pub actions: Option<Vec<String>>,
/// Source/Caster/Whoever initiated the action/sent the message
pub source: String,
/// Targets of the action, for a chat message this will be empty
pub targets: Option<Vec<String>>,
/// message id, should be left emitted or 0 for new messages
#[serde(default = "default_id")]
pub id: usize,
}
fn default_source() -> String {
String::new()
}
impl ChatMessage {
/// Creates a new chat message with a given text and source
pub fn new(text: String) -> Self {
Self {
text, ..Default::default()
}
}
pub fn source(mut self, source: String) -> Self {
self.source = source;
self
}
/// sets the whisper value of the message
pub fn whisper(mut self, whisper: Option<String>) -> Self {
self.whisper = whisper;
self
}
/// sets the roll value for the message (chaining multiple will override each other)
pub fn roll(mut self, roll: Option<Vec<RollDialogOption>>) -> Self {
self.roll = roll;
self
}
/// adds a single roll to the message (chaining multiple will add multiple rolls)
pub fn with_roll(mut self, roll: RollDialogOption) -> Self {
if let Some(rs) = &mut self.roll {
rs.push(roll);
}
else {
self.roll = Some(vec![roll]);
}
self
}
pub fn roll_target(mut self, target: Option<i32>) -> Self {
self.roll_target = target;
self
}
/// sets the actions value (chaining multiple will override)
pub fn actions(mut self, actions: Option<Vec<String>>) -> Self {
self.actions = actions;
self
}
/// adds a single action to the message (chaining multiple will add multiple actions)
pub fn with_action(mut self, action: String) -> Self {
if let Some(acts) = &mut self.actions {
acts.push(action);
}
else {
self.actions = Some(vec![action]);
}
self
}
/// sets the targets value (chaining multiple will override)
pub fn targets(mut self, targets: Option<Vec<String>>) -> Self {
self.targets = targets;
self
}
/// adds a single target to the message (chaining multiple will add multiple targets)
pub fn with_target(mut self, target: String) -> Self {
if let Some(targets) = &mut self.targets {
targets.push(target);
}
else {
self.targets = Some(vec![target]);
}
self
}
/// Sets the message id
///
/// WARNING: duplicate message id will cause an overwrite of the original message (and an edit at the client)
pub fn id(mut self, id: usize) -> Self {
self.id = id;
self
}
}
fn default_id() -> usize { 0 }

View file

@ -20,7 +20,7 @@ pub trait GameEntry : Serialize + Sized {
/// Get all categories (such as weapons/consumables/spells)
fn categories() -> Vec<String>;
/// returns a chat message to show the entry (with description and all)
fn to_chat(&self, source: &str) -> ChatMessage;
fn to_chat(&self) -> ChatMessage;
}
pub struct ActionDefinition {

View file

@ -1,75 +1,109 @@
use game::{chat_message::ChatMessage, Game, GameImpl};
use tokio::sync::{broadcast, mpsc};
pub mod user;
pub mod table;
pub mod api;
pub mod game;
pub mod pathfinder2r_impl;
pub mod table;
pub mod user;
pub struct GameServer {
_game: Game<pathfinder2r_impl::Pathfinder2rCharacterSheet, pathfinder2r_impl::entry::Entry>,
tokens: Vec<(String, i32, i32)>,
chat: Vec<(String, ChatMessage)>,
}
impl GameServer {
pub fn new() -> Self {
Self {
_game: Game::new(),
tokens: vec![("assets/pf2r/tokens/louise.jpg".to_string(), 2, 2)],
chat: Vec::new(),
}
}
pub async fn server_loop(mut self, mut msgs: mpsc::Receiver<(String, api::Request)>, broadcast: broadcast::Sender<(Option<String>, api::Response)>) {
pub async fn server_loop(
mut self,
mut msgs: mpsc::Receiver<(String, api::Request)>,
broadcast: broadcast::Sender<(Option<String>, api::Response)>,
) {
while let Some(req) = msgs.recv().await {
// TODO: do stuff yo!
let (id, req) = req;
println!("Got message from {}: {:?}", &id, &req);
match req {
api::Request::Error => {},
api::Request::Login(_) => {},
api::Request::Error => {}
api::Request::Login(_) => {}
api::Request::Message(mut msg) => {
if msg.id == 0 || msg.id >= self.chat.len() {
if msg.id == 0 || msg.id > self.chat.len() {
msg.id = self.chat.len() + 1; // set the message id, 0 is invalid
}
// TODO: check if the editor is an admin as well
else if id == self.chat[msg.id].0 {
self.chat[msg.id] = (id.clone(), msg.clone());
}
else {
else if id == self.chat[msg.id - 1].0 {
self.chat[msg.id - 1] = (id.clone(), msg.clone());
} else {
// if its an edit message and editor is not the owner, skip
continue;
}
if msg.source.is_empty() {
msg.source = format!("({})", id.clone());
}
else {
msg.source = format!("{} ({})", msg.source, id.clone());
}
// Force the sender id to be the new id of the message, even if an id was provided
msg.source = id.clone();
self.chat.push((id.clone(), msg.clone()));
_ = broadcast.send((None, api::Response::Message(msg)));
},
api::Request::Quit => { _ = broadcast.send((None, api::Response::Quit { id }))},
api::Request::Shutdown => todo!(),
api::Request::GetChatHistory { mut amount, from: last_msg } => {
if amount == 0 { amount = self.chat.len(); }
let history: Vec<ChatMessage> = self.chat.iter()
if msg.whisper.is_some() {
_ = broadcast.send((Some(id.clone()), api::Response::Message(msg.clone())));
}
_ = broadcast.send((msg.whisper.clone(), api::Response::Message(msg)));
}
api::Request::GetChatHistory {
mut amount,
from: last_msg,
} => {
if amount == 0 {
amount = self.chat.len();
}
let history: Vec<ChatMessage> = self
.chat
.iter()
.skip(last_msg)
.take(amount)
.map(|m| m.1.clone())
.collect();
_ = broadcast.send((Some(id), api::Response::GetChatHistory(history)));
},
}
api::Request::GetLastMessages { mut amount } => {
if amount == 0 { amount = self.chat.len(); }
let start = if amount >= self.chat.len() { self.chat.len() } else { self.chat.len() - amount };
let history: Vec<ChatMessage> = self.chat.iter()
.skip(start)
.map(|m| m.1.clone())
.collect();
if amount == 0 {
amount = self.chat.len();
}
let start = if amount >= self.chat.len() {
self.chat.len()
} else {
self.chat.len() - amount
};
let history: Vec<ChatMessage> =
self.chat.iter().skip(start).map(|m| m.1.clone()).collect();
_ = broadcast.send((Some(id), api::Response::GetChatHistory(history)));
},
api::Request::GetTokens => {
for (i, (path, x, y)) in self.tokens.iter().enumerate() {
let bits = std::fs::read(path).expect("FAILED READING TOKEN IMAGE");
let img = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &bits);
_ = broadcast.send((Some(id.clone()), api::Response::SpawnToken { token_id: i, x: *x, y: *y, img }));
}
},
api::Request::SpawnToken { x, y, img_path } => {
let token_id = self.tokens.len();
self.tokens.push((img_path.clone(), x, y));
let bits = std::fs::read(img_path).expect("FAILED READING TOKEN IMAGE");
let img = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &bits);
_ = broadcast.send((Some(id.clone()), api::Response::SpawnToken { token_id, x, y, img }));
},
api::Request::MoveToken { token_id, x, y } => {
// TODO: add check to make sure the actor is authorized to move the token
_ = broadcast.send((None, api::Response::MoveToken { token_id, x, y }));
},
api::Request::Quit => _ = broadcast.send((None, api::Response::Quit { id })),
api::Request::Kick(id) => _ = broadcast.send((Some(id), api::Response::Shutdown)),
api::Request::Shutdown => break,
}
}
_ = broadcast.send((None, api::Response::Shutdown));

View file

@ -31,6 +31,7 @@ async fn socket_receiver(mut recv: SplitStream<ws::WebSocket>, msend: mpsc::Send
if let Ok(msg) = msg {
match msg {
Message::Text(t) => {
println!("Got message from {}: {}", &id, &t);
let req = serde_json::from_str::<Request>(&t).unwrap_or_default();
let erred = msend.send((id.clone(), req)).await.is_err();
if erred {
@ -54,7 +55,8 @@ async fn socket_sender(id: String, mut send: SplitSink<ws::WebSocket, ws::Messag
while let Ok((to_id, msg)) = brecv.recv().await {
if to_id.is_none() || to_id.map(|t| t == id).unwrap_or(false) {
let err = send.send(ws::Message::Text(serde_json::to_string(&msg).unwrap())).await.is_err();
if err {
if err || matches!(msg, Response::Shutdown) {
_ = send.close().await;
break;
}
}

View file

@ -35,16 +35,9 @@ impl GameEntry for Entry {
}
}
fn to_chat(&self, source: &str) -> ChatMessage {
ChatMessage {
text: format!("{} - it might be a {{weapon/short_bow}} it might not", self.display_name()),
roll: None,
roll_target: None,
actions: None,
source: String::from(source),
targets: None,
id: 0,
}
fn to_chat(&self) -> ChatMessage {
let text = format!("{} - it might be a {{weapon/short_bow}} it might not", self.display_name());
ChatMessage::new(text)
}
fn all(_filter: Option<&str>) -> Vec<String> {

View file

@ -88,25 +88,15 @@ impl Pathfinder2rCharacterSheet {
impl Character<Entry> for Pathfinder2rCharacterSheet {
fn use_action(&mut self, entry: &Entry, action: &ActionResult) -> ChatMessage {
match entry {
Entry::Weapon(_) => ChatMessage {
text: String::from("Attack"),
roll: Some(vec![RollDialogOption { name: String::from("pierce"), dice_type: 4, dice_amount: 1, constant: 0, extra: String::new(), enabled: true }]),
roll_target: Some(10),
actions: Some(vec!["damage".to_string(), "double".to_string()]),
source: self.name.clone(),
targets: None,
id: 0,
},
Entry::Weapon(_) => ChatMessage::new("Attack".to_string())
.with_roll(RollDialogOption { name: String::from("pierce"), dice_type: 4, dice_amount: 1, constant: 0, extra: String::new(), enabled: true })
.roll_target(Some(10))
.with_action("damage".to_string())
.with_action("double".to_string()),
Entry::Consumable(_consumable) => if action.name == "consume" {
ChatMessage {
text: "Heal".to_string(),
roll: Some(vec![RollDialogOption { name: "heal".to_string(), dice_type: 6, dice_amount: 1, constant: 0, extra: String::new(), enabled: true }]),
roll_target: None,
actions: Some(vec!["heal".to_string()]),
source: self.name.clone(),
targets: None,
id: 0,
}
ChatMessage::new("Heal".to_string())
.with_roll(RollDialogOption { name: "heal".to_string(), dice_type: 6, dice_amount: 1, constant: 0, extra: String::new(), enabled: true })
.with_action("heal".to_string())
} else { todo!() },
Entry::Spell(_spell) => todo!(),
}