prompt ui for a few more roles + optional target

This commit is contained in:
emilis 2025-09-26 21:15:52 +01:00
parent 4ba77630c8
commit 09039c21c1
No known key found for this signature in database
14 changed files with 321 additions and 186 deletions

View File

@ -31,8 +31,6 @@ pub enum GameError {
TimedOut,
#[error("host channel closed")]
HostChannelClosed,
#[error("not all players connected")]
NotAllPlayersConnected,
#[error("too few players: got {got} but the settings require at least {need}")]
TooFewPlayers { got: u8, need: u8 },
#[error("it's already daytime")]

View File

@ -98,7 +98,7 @@ impl Night {
.partial_cmp(right_prompt)
.unwrap_or(core::cmp::Ordering::Equal)
});
let mut action_queue = VecDeque::from(action_queue);
let action_queue = VecDeque::from(action_queue);
let (current_prompt, current_char) = if night == 0 {
(
ActionPrompt::WolvesIntro {
@ -117,10 +117,18 @@ impl Night {
.clone(),
)
} else {
action_queue
.pop_front()
.map(|(p, c)| (p, c.character_id().clone()))
.ok_or(GameError::NoNightActions)?
(
ActionPrompt::WolfPackKill {
living_villagers: village.living_villagers(),
},
village
.living_wolf_pack_players()
.into_iter()
.next()
.unwrap()
.character_id()
.clone(),
)
};
let night_state = NightState::Active {
current_char,
@ -329,7 +337,9 @@ impl Night {
} => {}
}
}
if new_village.is_game_over().is_none() {
new_village.to_day()?;
}
Ok(new_village)
}

View File

@ -83,17 +83,23 @@ impl Village {
.find(|c| c.character_id() == character_id)
}
fn wolves_count(&self) -> usize {
self.characters.iter().filter(|c| c.is_wolf()).count()
fn living_wolves_count(&self) -> usize {
self.characters
.iter()
.filter(|c| c.is_wolf() && c.alive())
.count()
}
fn villager_count(&self) -> usize {
self.characters.iter().filter(|c| c.is_village()).count()
fn living_villager_count(&self) -> usize {
self.characters
.iter()
.filter(|c| c.is_village() && c.alive())
.count()
}
pub fn is_game_over(&self) -> Option<GameOver> {
let wolves = self.wolves_count();
let villagers = self.villager_count();
let wolves = self.living_wolves_count();
let villagers = self.living_villager_count();
if wolves == 0 {
return Some(GameOver::VillageWins);

View File

@ -1,7 +1,6 @@
use core::num::NonZeroU8;
use std::{collections::HashMap, sync::Arc};
use colored::Colorize;
use tokio::{
sync::{
Mutex,
@ -28,10 +27,6 @@ impl ConnectionId {
pub const fn player_id(&self) -> &PlayerId {
&self.0
}
pub const fn connect_time(&self) -> &Instant {
&self.1
}
}
#[derive(Debug)]
@ -67,10 +62,6 @@ impl JoinedPlayer {
pub fn resubscribe_reciever(&self) -> Receiver<ServerMessage> {
self.receiver.resubscribe()
}
pub fn sender(&self) -> Sender<ServerMessage> {
self.sender.clone()
}
}
#[derive(Debug, Clone)]
@ -120,16 +111,6 @@ impl JoinedPlayers {
}
}
pub async fn get_name(&self, player_id: &PlayerId) -> Option<String> {
self.players.lock().await.iter().find_map(|(pid, p)| {
if pid == player_id {
Some(p.name.clone())
} else {
None
}
})
}
/// Disconnect the player
///
/// Will not disconnect if the player is currently in a game, allowing them to reconnect
@ -153,11 +134,11 @@ impl JoinedPlayers {
pub async fn start_game_with(&self, players: &[PlayerId]) -> Result<InGameToken, GameError> {
let mut map = self.players.lock().await;
if !players.iter().all(|p| map.contains_key(p)) {
return Err(GameError::NotAllPlayersConnected);
}
for player in players {
unsafe { map.get_mut(player).unwrap_unchecked() }.in_game = true;
if let Some(player) = map.get_mut(player) {
player.in_game = true;
};
}
Ok(InGameToken::new(

View File

@ -4,7 +4,7 @@ use crate::{
LogError,
communication::{Comms, lobby::LobbyComms},
connection::{InGameToken, JoinedPlayers},
lobby::{Lobby, PlayerIdSender},
lobby::{Lobby, LobbyPlayers},
runner::{IdentifiedClientMessage, Message},
};
use tokio::{sync::broadcast::Receiver, time::Instant};
@ -24,18 +24,18 @@ pub struct GameRunner {
game: Game,
comms: Comms,
connect_recv: Receiver<(PlayerId, bool)>,
player_sender: PlayerIdSender,
player_sender: LobbyPlayers,
roles_revealed: bool,
joined_players: JoinedPlayers,
_release_token: InGameToken,
// _release_token: InGameToken,
cover_of_darkness: bool,
}
impl GameRunner {
pub const fn new(
pub fn new(
game: Game,
comms: Comms,
player_sender: PlayerIdSender,
player_sender: LobbyPlayers,
connect_recv: Receiver<(PlayerId, bool)>,
joined_players: JoinedPlayers,
release_token: InGameToken,
@ -47,7 +47,7 @@ impl GameRunner {
player_sender,
joined_players,
roles_revealed: false,
_release_token: release_token,
// _release_token: release_token,
cover_of_darkness: true,
}
}
@ -57,10 +57,13 @@ impl GameRunner {
}
pub fn into_lobby(self) -> Lobby {
Lobby::new(
// core::mem::drop(self._release_token);
let mut lobby = Lobby::new(
self.joined_players,
LobbyComms::new(self.comms, self.connect_recv),
)
);
lobby.set_players_in_lobby(self.player_sender);
lobby
}
pub const fn proto_game(&self) -> &Game {
@ -106,7 +109,7 @@ impl GameRunner {
.log_err();
};
(update_host)(&acks, &mut self.comms);
let notify_of_role = |player_id: &PlayerId, village: &Village, sender: &PlayerIdSender| {
let notify_of_role = |player_id: &PlayerId, village: &Village, sender: &LobbyPlayers| {
if let Some(char) = village.character_by_player_id(player_id) {
sender
.send_if_present(
@ -245,23 +248,6 @@ impl GameEnd {
}
}
pub fn end_screen(&mut self) -> Result<()> {
let result = self.result;
for char in self.game()?.game.village().characters() {
self.game()?
.player_sender
.send_if_present(char.player_id(), ServerMessage::GameOver(result))
.log_debug();
}
self.game()?
.comms
.host()
.send(ServerToHostMessage::GameOver(result))
.log_warn();
Ok(())
}
pub async fn next(&mut self) -> Option<Lobby> {
let msg = match self.game().unwrap().comms.message().await {
Ok(msg) => msg,

View File

@ -27,7 +27,7 @@ use crate::{
};
pub struct Lobby {
players_in_lobby: PlayerIdSender,
players_in_lobby: LobbyPlayers,
settings: GameSettings,
joined_players: JoinedPlayers,
comms: Option<LobbyComms>,
@ -39,10 +39,14 @@ impl Lobby {
joined_players,
comms: Some(comms),
settings: GameSettings::default(),
players_in_lobby: PlayerIdSender(Vec::new()),
players_in_lobby: LobbyPlayers(Vec::new()),
}
}
pub fn set_players_in_lobby(&mut self, players_in_lobby: LobbyPlayers) {
self.players_in_lobby = players_in_lobby
}
const fn comms(&mut self) -> Result<&mut LobbyComms, GameError> {
match self.comms.as_mut() {
Some(comms) => Ok(comms),
@ -80,7 +84,7 @@ impl Lobby {
players.into_boxed_slice()
}
async fn send_lobby_info_to_host(&mut self) -> Result<(), GameError> {
pub async fn send_lobby_info_to_host(&mut self) -> Result<(), GameError> {
let players = self.get_lobby_player_list().await;
self.comms()?
.host()
@ -203,7 +207,7 @@ impl Lobby {
return Ok(Some(GameRunner::new(
game,
comms,
self.players_in_lobby.drain(),
self.players_in_lobby.clone(),
recv,
self.joined_players.clone(),
release_token,
@ -300,9 +304,10 @@ impl Lobby {
}
}
pub struct PlayerIdSender(Vec<(Identification, Sender<ServerMessage>)>);
#[derive(Clone)]
pub struct LobbyPlayers(Vec<(Identification, Sender<ServerMessage>)>);
impl Deref for PlayerIdSender {
impl Deref for LobbyPlayers {
type Target = Vec<(Identification, Sender<ServerMessage>)>;
fn deref(&self) -> &Self::Target {
@ -310,13 +315,13 @@ impl Deref for PlayerIdSender {
}
}
impl DerefMut for PlayerIdSender {
impl DerefMut for LobbyPlayers {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl PlayerIdSender {
impl LobbyPlayers {
pub fn find(&self, player_id: &PlayerId) -> Option<&Sender<ServerMessage>> {
self.iter()
.find_map(|(id, s)| (&id.player_id == player_id).then_some(s))

View File

@ -1,13 +1,12 @@
use core::time::Duration;
use thiserror::Error;
use werewolves_proto::{
error::GameError,
message::{ClientMessage, Identification, host::HostMessage},
player::PlayerId,
};
use crate::{
LogError,
communication::lobby::LobbyComms,
connection::JoinedPlayers,
game::{GameEnd, GameRunner},
@ -15,14 +14,6 @@ use crate::{
saver::Saver,
};
#[derive(Debug, Error)]
enum GameOrSendError<T> {
#[error("game error: {0}")]
GameError(#[from] GameError),
#[error("send error: {0}")]
SendError(#[from] tokio::sync::mpsc::error::SendError<T>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct IdentifiedClientMessage {
pub identity: Identification,
@ -76,6 +67,7 @@ pub async fn run_game(joined_players: JoinedPlayers, comms: LobbyComms, mut save
RunningState::GameOver(end) => {
if let Some(mut new_lobby) = end.next().await {
new_lobby.send_lobby_info_to_clients().await;
new_lobby.send_lobby_info_to_host().await.log_debug();
state = RunningState::Lobby(new_lobby)
}
}

View File

@ -3,6 +3,7 @@ $village_color: rgba(0, 0, 255, 0.7);
$connected_color: hsl(120, 68%, 50%);
$disconnected_color: hsl(0, 68%, 50%);
html,
body {
margin: 0;
@ -1015,4 +1016,8 @@ error {
background-color: $disconnected_color;
border: 3px solid darken($disconnected_color, 20%);
}
&.dead {
filter: grayscale(100%);
}
}

View File

@ -2,7 +2,7 @@ use core::fmt::Debug;
use std::sync::Arc;
use futures::SinkExt;
use yew::{html::Scope, prelude::*};
use yew::prelude::*;
pub fn send_message<T: Clone + Debug + 'static, P>(
msg: T,
@ -19,13 +19,6 @@ pub fn send_message<T: Clone + Debug + 'static, P>(
})
}
pub fn mouse_event<F>(inner: F) -> Callback<MouseEvent>
where
F: Fn() + 'static,
{
Callback::from(move |_| (inner)())
}
pub fn send_fn<T, P, F>(msg_fn: F, send: futures::channel::mpsc::Sender<T>) -> Callback<P>
where
T: Clone + Debug + 'static,

View File

@ -12,7 +12,7 @@ use yew::prelude::*;
use crate::components::{
Identity,
action::{SingleTarget, WolvesIntro},
action::{SingleTarget, TargetSelection, WolvesIntro},
};
#[derive(Debug, Clone, PartialEq, Properties)]
@ -48,17 +48,20 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
}
ActionPrompt::Seer { living_players } => {
let on_complete = props.on_complete.clone();
let on_select = Callback::from(move |target: CharacterId| {
let on_select = props.big_screen.not().then(|| {
Callback::from(move |target: CharacterId| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::Seer(target)),
)));
})
.into()
});
html! {
<div>
{ident}
<SingleTarget
targets={living_players.clone()}
on_select={on_select}
target_selection={on_select}
headline={"check alignment"}
/>
</div>
@ -85,9 +88,47 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
</div>
}
}
ActionPrompt::Protector { targets } => todo!(),
ActionPrompt::Protector { targets } => {
let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| {
Callback::from(move |target: CharacterId| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::Protector(target)),
)));
})
.into()
});
html! {
<div>
<SingleTarget
targets={targets.clone()}
target_selection={on_select}
headline={"protector"}
/>
</div>
}
}
ActionPrompt::Arcanist { living_players } => todo!(),
ActionPrompt::Gravedigger { dead_players } => todo!(),
ActionPrompt::Gravedigger { dead_players } => {
let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| {
Callback::from(move |target: CharacterId| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::Gravedigger(target)),
)));
})
.into()
});
html! {
<div>
<SingleTarget
targets={dead_players.clone()}
target_selection={on_select}
headline={"gravedigger"}
/>
</div>
}
}
ActionPrompt::Hunter {
current_target,
living_players,
@ -101,9 +142,62 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
previous,
living_players,
} => todo!(),
ActionPrompt::WolfPackKill { living_villagers } => todo!(),
ActionPrompt::WolfPackKill { living_villagers } => {
let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| {
Callback::from(move |target: CharacterId| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::WolfPackKillVote(target)),
)));
})
.into()
});
html! {
<div>
<SingleTarget
targets={living_villagers.clone()}
target_selection={on_select}
headline={"wolf pack kill"}
/>
</div>
}
}
ActionPrompt::Shapeshifter => todo!(),
ActionPrompt::AlphaWolf { living_villagers } => todo!(),
ActionPrompt::DireWolf { living_players } => todo!(),
ActionPrompt::AlphaWolf { living_villagers } => {
let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| {
Callback::from(move |target: Option<CharacterId>| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::AlphaWolf(target)),
)));
})
.into()
});
html! {
<SingleTarget
targets={living_villagers.clone()}
target_selection={on_select}
headline={"alpha wolf target"}
/>
}
}
ActionPrompt::DireWolf { living_players } => {
let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| {
Callback::from(move |target: CharacterId| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::Direwolf(target)),
)));
})
.into()
});
html! {
<SingleTarget
targets={living_players.clone()}
target_selection={on_select}
headline={"direwolf block target"}
/>
}
}
}
}

View File

@ -1,22 +1,48 @@
use core::ops::Not;
use core::{fmt::Debug, marker::PhantomData, ops::Not};
use werewolves_proto::{message::Target, player::CharacterId};
use yew::{html::Scope, prelude::*};
use yew::prelude::*;
use crate::components::Identity;
#[derive(Debug, Clone, PartialEq)]
pub enum TargetSelection {
SingleOptional(Callback<Option<CharacterId>>),
Single(Callback<CharacterId>),
}
impl From<Callback<CharacterId>> for TargetSelection {
fn from(value: Callback<CharacterId>) -> Self {
Self::Single(value)
}
}
impl From<Callback<Option<CharacterId>>> for TargetSelection {
fn from(value: Callback<Option<CharacterId>>) -> Self {
Self::SingleOptional(value)
}
}
impl TargetSelection {
pub const fn button_disabled(&self, selected: &[CharacterId]) -> bool {
match self {
TargetSelection::SingleOptional(_) => selected.len() > 1,
TargetSelection::Single(_) => selected.len() != 1,
}
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct SingleTargetProps {
pub targets: Box<[Target]>,
#[prop_or_default]
pub headline: &'static str,
#[prop_or_default]
pub read_only: bool,
pub on_select: Callback<CharacterId>,
pub target_selection: Option<TargetSelection>,
}
pub struct SingleTarget {
selected: Option<CharacterId>,
selected: Vec<CharacterId>,
}
impl Component for SingleTarget {
@ -25,41 +51,76 @@ impl Component for SingleTarget {
type Properties = SingleTargetProps;
fn create(_: &Context<Self>) -> Self {
Self { selected: None }
Self {
selected: Vec::new(),
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let SingleTargetProps { read_only, headline, targets, on_select } = ctx.props();
let SingleTargetProps {
headline,
targets,
target_selection,
} = ctx.props();
let target_selection = target_selection.clone();
let scope = ctx.link().clone();
let card_select = Callback::from(move |target| {
scope.send_message(target);
});
let targets = targets.iter().map(|t| {
let targets = targets
.iter()
.map(|t| {
html! {
<TargetCard
target={t.clone()}
selected={self.selected.as_ref().map(|sel| sel == &t.character_id).unwrap_or_default()}
selected={self.selected.iter().any(|sel| sel == &t.character_id)}
on_select={card_select.clone()}
/>
}
}).collect::<Html>();
})
.collect::<Html>();
let headline = if headline.trim().is_empty() {
html!()
} else {
html!(<h2>{headline}</h2>)
};
let on_select = on_select.clone();
let on_click = if let Some(target) = self.selected.clone() {
Callback::from(move |_| on_select.emit(target.clone()))
let on_click =
target_selection
.as_ref()
.and_then(|target_selection| match target_selection {
TargetSelection::SingleOptional(on_click) => {
if self.selected.len() > 1 {
None
} else {
Callback::from(|_| ())
};
let selected = self.selected.iter().next().cloned();
let on_click = on_click.clone();
Some(Callback::from(move |_| on_click.emit(selected.clone())))
}
}
TargetSelection::Single(on_click) => {
if self.selected.len() != 1 {
None
} else {
let selected = self.selected[0].clone();
let on_click = on_click.clone();
Some(Callback::from(move |_| on_click.emit(selected.clone())))
}
}
});
let submit = read_only.not().then(|| html!{
let submit = target_selection.as_ref().map(|target_selection| {
let disabled = target_selection.button_disabled(&self.selected);
html! {
<div class="button-container sp-ace">
<button disabled={self.selected.is_none()} onclick={on_click}>{"submit"}</button>
<button
disabled={disabled}
onclick={on_click}
>
{"submit"}
</button>
</div>
}
});
html! {
@ -74,14 +135,15 @@ impl Component for SingleTarget {
}
fn update(&mut self, _: &Context<Self>, msg: Self::Message) -> bool {
if let Some(selected) = self.selected.as_ref() {
if selected == &msg {
self.selected = None;
if let Some(idx) = self
.selected
.iter()
.enumerate()
.find_map(|(idx, c)| (c == &msg).then_some(idx))
{
self.selected.swap_remove(idx);
} else {
self.selected = Some(msg);
}
} else {
self.selected = Some(msg);
self.selected.push(msg);
}
true
}

View File

@ -14,7 +14,7 @@ mod callback;
use core::num::NonZeroU8;
use pages::{Client, ErrorComponent, Host, WerewolfError};
use web_sys::{Element, HtmlElement, Url, wasm_bindgen::JsCast};
use web_sys::Url;
use werewolves_proto::{
message::{Identification, PublicIdentity},
player::PlayerId,

View File

@ -18,7 +18,7 @@ use werewolves_proto::{
Target,
night::{ActionPrompt, ActionResponse, ActionResult},
},
player::{Character, CharacterId, PlayerId},
player::PlayerId,
role::RoleTitle,
};
use yew::{html::Scope, prelude::*};

View File

@ -261,22 +261,20 @@ impl Component for Host {
log::info!("state: {:?}", self.state);
let content = match self.state.clone() {
HostState::GameOver { result } => {
let send = self.send.clone();
let new_lobby = Callback::from(move |_| {
let send = send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.clone().send(HostMessage::NewLobby).await {
log::error!("send new lobby: {err}");
}
});
let new_lobby = self.big_screen.not().then(|| {
crate::callback::send_message(HostMessage::NewLobby, self.send.clone())
});
html! {
<div>
<p>{format!("game over: {result:?}")}</p>
<div class="button-container">
<button onclick={new_lobby}>{"New Lobby"}</button>
</div>
</div>
<CoverOfDarkness
message={match result {
GameOver::VillageWins => "village wins",
GameOver::WolvesWin => "wolves win",
}}
next={new_lobby}
>
{"new lobby"}
</CoverOfDarkness>
}
}
HostState::Disconnected => html! {
@ -497,6 +495,12 @@ impl Component for Host {
players: p,
settings: _,
} => *p = players,
HostState::GameOver { result: _ } => {
self.state = HostState::Lobby {
players,
settings: GameSettings::default(),
}
}
HostState::CoverOfDarkness
| HostState::Prompt(_, _)
| HostState::Result(_, _)
@ -505,7 +509,6 @@ impl Component for Host {
ackd: _,
waiting: _,
}
| HostState::GameOver { result: _ }
| HostState::Day {
characters: _,
day: _,