daytime port

This commit is contained in:
emilis 2026-02-21 00:04:36 +00:00
parent 80859f58d0
commit c8e51f36e2
No known key found for this signature in database
8 changed files with 355 additions and 28 deletions

View File

@ -1,5 +1,5 @@
.icon-fit { .icon-fit {
// height: 1em; height: 1em;
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;

View File

@ -935,3 +935,96 @@ form {
gap: 1ch; gap: 1ch;
font-size: 1.25em; font-size: 1.25em;
} }
.top-of-day-info {
width: max-content;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
padding: 10px;
gap: 10vw;
.info-tidbit {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
gap: 5px;
label {
font-size: 1.5em;
opacity: 70%;
}
.parity {
font-size: 2em;
}
.parity-pct {
font-size: 1.25em;
}
.last-nights-kills {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2cm;
.identity {
.number {
color: red;
font-size: 1.5em;
}
text-align: center;
font-size: 1.25em;
}
}
.current-day {
color: red;
font-size: 3em;
flex-grow: 1;
}
}
}
.player-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5ch;
padding-bottom: 1ch;
.character {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1ch;
padding: 1ch;
font-size: 1.25em;
.headline {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 0.5ch;
align-items: center;
justify-content: center;
}
background-color: $village_color_faint;
border: 1px solid $village_border_faint;
&.marked {
background-color: color.change($red2, $alpha: 0.3);
border: 1px solid color.change($red2, $alpha: 0.8);
}
}
}

View File

@ -1,4 +1,5 @@
.cover-of-darkness { .cover-of-darkness {
background-color: black;
font-size: 3em; font-size: 3em;
position: fixed; position: fixed;
top: 0; top: 0;

View File

@ -164,12 +164,10 @@ pub fn BigScreen() -> impl IntoView {
} }
} }
ServerToHostMessage::Disconnect => disconnect.set(true), ServerToHostMessage::Disconnect => disconnect.set(true),
ServerToHostMessage::Daytime { ServerToHostMessage::Daytime { settings: s, .. } => {
characters, settings.set(s);
marked, page.set(BigScreenPage::Setup);
day, }
settings,
} => todo!(),
ServerToHostMessage::PlayerStates(_) => {} ServerToHostMessage::PlayerStates(_) => {}
ServerToHostMessage::ActionPrompt(act, ppage) => { ServerToHostMessage::ActionPrompt(act, ppage) => {
page.set(BigScreenPage::ActionPrompt { page.set(BigScreenPage::ActionPrompt {
@ -229,10 +227,15 @@ pub fn BigScreen() -> impl IntoView {
BigScreenPage::ActionPrompt { prompt, page } => { BigScreenPage::ActionPrompt { prompt, page } => {
view! { <RolePrompt prompt=prompt page=page /> }.into_any() view! { <RolePrompt prompt=prompt page=page /> }.into_any()
} }
BigScreenPage::ActionResult { character, result } => view! { <RoleResult character=character result=result /> } BigScreenPage::ActionResult { character, result } => {
.into_any(), view! { <RoleResult character=character result=result /> }.into_any()
}
}; };
view! { <div class="big-screen-wrapper">{content}</div> }.into_any() view! {
<div class:big-screen-wrapper=move || {
!matches!(page.get(), BigScreenPage::Setup)
}>{content}</div>
}.into_any()
} }
} }

View File

@ -1,16 +1,15 @@
werewolves_macros::include_path!("werewolves/src/app/pages/game/host"); werewolves_macros::include_path!("werewolves/src/app/pages/game/host");
use core::num::NonZeroU8;
use std::collections::HashMap; use std::collections::HashMap;
use leptos::prelude::*; use leptos::prelude::*;
use werewolves_proto::{ use werewolves_proto::{
character::CharacterId,
game::{Category, GameSettings}, game::{Category, GameSettings},
message::{ message::{
CharacterIdentity, PlayerState, CharacterIdentity, CharacterState, PlayerState,
host::{ host::{HostGameMessage, HostLobbyMessage, HostMessage, ServerToHostMessage as Srv2Host},
HostGameMessage, HostLobbyMessage, HostMessage, HostNightMessage,
ServerToHostMessage as Srv2Host,
},
night::{ActionPrompt, ActionResult}, night::{ActionPrompt, ActionResult},
}, },
}; };
@ -36,6 +35,12 @@ enum HostPage {
character: Option<CharacterIdentity>, character: Option<CharacterIdentity>,
result: ActionResult, result: ActionResult,
}, },
Daytime {
day: NonZeroU8,
characters: Box<[CharacterState]>,
marked: Box<[CharacterId]>,
reply: WriteSignal<Option<HostMessage>>,
},
} }
#[component] #[component]
@ -118,6 +123,19 @@ pub fn HostGamePage(
Srv2Host::ActionResult(character, result) => { Srv2Host::ActionResult(character, result) => {
page.set(HostPage::ActionResult { character, result }) page.set(HostPage::ActionResult { character, result })
} }
Srv2Host::Daytime {
characters,
marked,
day,
..
} => {
page.set(HostPage::Daytime {
day,
characters,
marked,
reply,
});
}
_ => log::error!("{message:#?}"), _ => log::error!("{message:#?}"),
} }
} }
@ -165,6 +183,15 @@ pub fn HostGamePage(
view! { <RoleResult reply=reply_result.write_only() character=character result=result /> } view! { <RoleResult reply=reply_result.write_only() character=character result=result /> }
.into_any() .into_any()
} }
HostPage::Daytime {
day,
characters,
marked,
reply,
} => view! {
<DaytimePlayerList day=day characters=characters marked=marked reply=reply/>
}
.into_any(),
}; };
view! { view! {
{cancel} {cancel}

View File

@ -0,0 +1,187 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::{num::NonZeroU8, ops::Not};
use convert_case::{Case, Casing};
use leptos::prelude::*;
use leptos::{IntoView, prelude::WriteSignal};
use werewolves_proto::message::host::{HostDayMessage, HostGameMessage};
use werewolves_proto::{
character::CharacterId,
game::GameTime,
message::{CharacterState, host::HostMessage},
};
use crate::app::components::{
AssociatedIcon, DialogModal, Icon, IconType, IdentityInline, PartialAssociatedIcon,
};
#[component]
pub fn DaytimePlayerList(
#[prop(optional)] day: Option<NonZeroU8>,
characters: Box<[CharacterState]>,
marked: Box<[CharacterId]>,
reply: WriteSignal<Option<HostMessage>>,
) -> impl IntoView {
let mut characters = characters.clone();
let last_nights_kills = {
let kills = characters
.iter()
.filter_map(|c| {
c.died_to
.as_ref()
.and_then(|died_to| match died_to.date_time() {
GameTime::Day { .. } => None,
GameTime::Night { number } => {
if let Some(day) = day.as_ref()
&& number == day.get() - 1
{
Some(c)
} else {
None
}
}
})
})
.map(|killed| {
view! {
<span>
<IdentityInline ident=killed.identity.clone().into_public() />
</span>
}
})
.collect::<Box<[_]>>();
kills.is_empty().not().then_some(view! {
<div class="info-tidbit">
<label>{"died last night"}</label>
<div class="last-nights-kills">{kills.into_iter().collect_view()}</div>
</div>
})
};
characters.sort_by(|l, r| l.identity.number.cmp(&r.identity.number));
let chars = characters
.iter()
.map(|c| {
view! {
<DaytimePlayer
character=c.clone()
reply=reply
marked=marked.contains(&c.identity.character_id)
/>
}
})
.collect_view();
let (button_text, confirmation_text) = if marked.is_empty() {
(
"end day".to_string(),
"really end the day with no executions?".to_string(),
)
} else if marked.len() == 1 {
(
"execute 1 player".to_string(),
characters
.iter()
.find(|c| c.identity.character_id == marked[0])
.map(|c| c.identity.clone().into_public())
.map(|id| format!("really execute {id}?"))
.unwrap_or("really execute 1 player?".to_string()),
)
} else {
(
format!("execute {} players", marked.len()),
format!("really execute {} players?", marked.len()),
)
};
let parity = {
let wolves = characters
.iter()
.filter(|c| c.died_to.is_none() && c.role.wolf())
.count();
let total = characters.iter().filter(|c| c.died_to.is_none()).count();
let pct_parity = (((wolves as f64) * 100.0) / (total as f64)).round();
view! {
<div class="info-tidbit">
<label>{"parity"}</label>
<span class="parity">
<span class="red">{wolves}</span>
{"/"}
<span class="total">{total}</span>
</span>
<span class="parity-pct">{"("} {pct_parity} {"%)"}</span>
</div>
}
};
let button = view! {
<DialogModal button_content=button_text.clone()>
<h3>{confirmation_text.clone()}</h3>
<button on:click=move |_| {
reply
.set(
Some(HostMessage::InGame(HostGameMessage::Day(HostDayMessage::Execute))),
)
}>{button_text.clone()}</button>
</DialogModal>
};
let day = day.as_ref().map(|day| {
view! {
<div class="info-tidbit">
<label>{"day"}</label>
<span class="current-day">{day.get()}</span>
</div>
}
});
view! {
<div class="character-picker">
<div class="top-of-day-info">{day} {parity} {last_nights_kills}</div>
<div class="player-list">{chars}</div>
{button}
</div>
}
}
#[component]
fn DaytimePlayer(
character: CharacterState,
reply: WriteSignal<Option<HostMessage>>,
marked: bool,
) -> impl IntoView {
let CharacterState {
identity,
role,
died_to,
..
} = character;
let character_id = identity.character_id;
let select = move |_| {
reply.set(Some(HostMessage::InGame(HostGameMessage::Day(
HostDayMessage::MarkForExecution(character_id),
))))
};
let icon = role.icon().unwrap_or_else(|| role.alignment().icon());
let text = role.to_string().to_case(Case::Title);
let align_class = role.wolf().then_some("red");
view! {
<button on:click=select class="character no-hover" class:dead=died_to.is_some() class:marked=marked>
<div class="day-char">
<span class="headline">
<Icon source=icon r#type=IconType::Small />
<span class=align_class>{text}</span>
</span>
<IdentityInline ident=identity.into_public() />
</div>
</button>
}
}

View File

@ -23,21 +23,39 @@ pub fn RoleResult(
#[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>, #[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>,
) -> impl IntoView { ) -> impl IntoView {
let body = match result { let body = match result {
ActionResult::RoleBlocked => view! { <RoleblockPage /> }.into_any(), ActionResult::RoleBlocked => view!{
ActionResult::Drunk => view! { <DrunkPage /> }.into_any(), <RoleblockPage />
ActionResult::Seer(target, alignment) => view! { <SeerResult target=target.into_public() alignment=alignment /> }.into_any(), }.into_any(),
ActionResult::PowerSeer { target, powerful } => view! { <PowerSeerResult target=target.into_public() powerful=powerful /> }.into_any(), ActionResult::Drunk => view! {
ActionResult::Adjudicator { target, killer } => view! { <AdjudicatorResult target=target.into_public() killer=killer /> }.into_any(), <DrunkPage />
}.into_any(),
ActionResult::Seer(target, alignment) => view!{
<SeerResult target=target.into_public() alignment=alignment/>
}.into_any(),
ActionResult::PowerSeer { target, powerful } => view!{
<PowerSeerResult target=target.into_public() powerful=powerful/>
}.into_any(),
ActionResult::Adjudicator { target, killer } => view!{
<AdjudicatorResult target=target.into_public() killer=killer/>
}.into_any(),
ActionResult::Arcanist((target1, target2), alignment_eq) => view! { ActionResult::Arcanist((target1, target2), alignment_eq) => view! {
<ArcanistResult <ArcanistResult
value=alignment_eq value=alignment_eq
targets=(target1.into_public(), target2.into_public()) targets=(target1.into_public(), target2.into_public())
/> />
}.into_any(), }.into_any(),
ActionResult::GraveDigger(target, role) => view! { <GravediggerResultPage target=target.into_public() role=role /> }.into_any(), ActionResult::GraveDigger(target, role) => view! {
ActionResult::Mortician(target, died_to) => view! { <MorticianResultPage target=target.into_public() died_to=died_to /> }.into_any(), <GravediggerResultPage target=target.into_public() role=role/>
ActionResult::Insomniac(visits) => view! { <InsomniacResult visits=visits /> }.into_any(), }.into_any(),
ActionResult::Empath { target, scapegoat } => view! { <EmpathResult target=target.into_public() scapegoat=scapegoat /> }.into_any(), ActionResult::Mortician(target, died_to) => view!{
<MorticianResultPage target=target.into_public() died_to=died_to />
}.into_any(),
ActionResult::Insomniac(visits) => view!{
<InsomniacResult visits=visits/>
}.into_any(),
ActionResult::Empath { target, scapegoat } => view! {
<EmpathResult target=target.into_public() scapegoat=scapegoat />
}.into_any(),
ActionResult::BeholderSawNothing => todo!(), ActionResult::BeholderSawNothing => todo!(),
ActionResult::BeholderSawEverything => todo!(), ActionResult::BeholderSawEverything => todo!(),
ActionResult::GoBackToSleep => return match reply { ActionResult::GoBackToSleep => return match reply {
@ -51,9 +69,7 @@ pub fn RoleResult(
let Some(reply) = reply else { let Some(reply) = reply else {
return; return;
}; };
reply.set(Some(HostNightMessage::ActionResponse( reply.set(Some(HostNightMessage::Next));
ActionResponse::ContinueToResult,
)));
}); });
().into_any() ().into_any()
} }

View File

@ -1,5 +1,5 @@
#![allow(clippy::expect_fun_call)] #![allow(clippy::expect_fun_call)]
#![allow(clippy::boxed_local)]
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
mod ssr { mod ssr {
pub const DEFAULT_MAX_PG_CONNECTIONS: u32 = 30; pub const DEFAULT_MAX_PG_CONNECTIONS: u32 = 30;