character: drop player card; RU headings; growable attunements; AC
- PlayerCard removed — it duplicated the sidebar editors; the character page now shows only prepared spells and attunements - headings are plain RU: Заготовленные заклинания / Атюнменты (no EN/RU double labels; h6 no longer lowercased) - Attunements is Vec<Option<String>> instead of a fixed 3-slot array; '+ слот' appends a slot, so custom campaigns can track more items (old saved JSON parses: arrays map to Vec) - PlayerData.ac (u8, serde default 0): manual armor-class input in the sidebar HP block (hp/оз, ac/КД), editable like the HP numbers
This commit is contained in:
parent
6d0846d160
commit
294bf430fd
@ -12,7 +12,7 @@ pub fn Attunements () -> impl IntoView {
|
|||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="attunements">
|
<div class="attunements">
|
||||||
<h6>"attunements/слоты привязки:"</h6>
|
<h6>"Атюнменты:"</h6>
|
||||||
<For
|
<For
|
||||||
// Same state-in-key convention as prepared spells, so a
|
// Same state-in-key convention as prepared spells, so a
|
||||||
// slot whose value changed re-renders.
|
// slot whose value changed re-renders.
|
||||||
@ -22,6 +22,13 @@ pub fn Attunements () -> impl IntoView {
|
|||||||
>
|
>
|
||||||
{slot_row(attunements, idx, slot)}
|
{slot_row(attunements, idx, slot)}
|
||||||
</For>
|
</For>
|
||||||
|
<button
|
||||||
|
class="add-slot"
|
||||||
|
title="Добавить слот привязки"
|
||||||
|
on:click=move |_| attunements.update(|a| a.0.push(None))
|
||||||
|
>
|
||||||
|
"+ слот"
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
//! Character page blocks (route `/`): player card, prepared spells and
|
//! Character page blocks (route `/`): prepared spells and attunements.
|
||||||
//! attunements. The sidebar already carries the detailed editors (names,
|
//! The sidebar carries the character editors (names, class/level/XP,
|
||||||
//! class/level/XP, HP, hit dice, death saves, spell slots); this module
|
//! HP, hit dice, death saves, spell slots); this module renders the
|
||||||
//! renders the page-level view of that data.
|
//! page-level view.
|
||||||
|
|
||||||
pub mod attunements;
|
pub mod attunements;
|
||||||
pub mod player_card;
|
|
||||||
pub mod prepared_spells;
|
pub mod prepared_spells;
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
use crate::prelude::*;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
|
|
||||||
/// Read-only player summary: portrait, name, class, level and XP.
|
|
||||||
///
|
|
||||||
/// The sidebar holds the editors for this data (names, about); this card
|
|
||||||
/// presents it at the top of the character page.
|
|
||||||
#[component]
|
|
||||||
pub fn PlayerCard () -> impl IntoView {
|
|
||||||
let state = expect_context::<Context>();
|
|
||||||
let player = state.player();
|
|
||||||
|
|
||||||
let image = player.image();
|
|
||||||
let name = player.name();
|
|
||||||
let class = player.class();
|
|
||||||
let level = player.level();
|
|
||||||
let xp = player.xp();
|
|
||||||
|
|
||||||
// "First «alias» Last" with the optional parts dropped when empty.
|
|
||||||
let display_name = move || {
|
|
||||||
let n = name.get();
|
|
||||||
let mut s = n.first.clone();
|
|
||||||
if n.display_alias() {
|
|
||||||
s.push_str(&format!(" «{}»", n.alias));
|
|
||||||
}
|
|
||||||
if n.display_last() {
|
|
||||||
s.push_str(&format!(" {}", n.last));
|
|
||||||
}
|
|
||||||
s
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class="player-card">
|
|
||||||
<Show when=move || !image.get().is_empty()>
|
|
||||||
<img src=move || image.get() alt="portrait" />
|
|
||||||
</Show>
|
|
||||||
<div class="player-meta">
|
|
||||||
<div class="player-name">{display_name}</div>
|
|
||||||
<div class="player-class">{move || class.get()}</div>
|
|
||||||
<div class="player-level">{move || format!("уровень {}", level.get())}</div>
|
|
||||||
<div class="player-xp">{move || format!("{} XP", xp.get())}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -16,7 +16,7 @@ pub fn PreparedSpells () -> impl IntoView {
|
|||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="prepared-spells">
|
<div class="prepared-spells">
|
||||||
<h6>"prepared spells/подготовленные заклинания:"</h6>
|
<h6>"Заготовленные заклинания:"</h6>
|
||||||
<For
|
<For
|
||||||
// The key carries the slot state, so a slot whose state
|
// The key carries the slot state, so a slot whose state
|
||||||
// changed re-renders (same convention as sidebar pips).
|
// changed re-renders (same convention as sidebar pips).
|
||||||
|
|||||||
@ -24,14 +24,12 @@ pub mod numput;
|
|||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
|
||||||
/// Character page (route `/`): player card, prepared spells and
|
/// Character page (route `/`): prepared spells and attunements
|
||||||
/// attunements (ROADMAP phase 2). The sidebar carries the detailed
|
/// (ROADMAP phase 2). The sidebar carries the character editors.
|
||||||
/// editors; the HP block with its Numput-style inputs lives there too.
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Character () -> impl IntoView {
|
pub fn Character () -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div class="character-page">
|
<div class="character-page">
|
||||||
<character::player_card::PlayerCard />
|
|
||||||
<character::prepared_spells::PreparedSpells />
|
<character::prepared_spells::PreparedSpells />
|
||||||
<character::attunements::Attunements />
|
<character::attunements::Attunements />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,8 @@ use crate::prelude::*;
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
/// Current/max HP with a fill bar, plus temp HP with its own bar.
|
/// Current/max HP with a fill bar, plus temp HP with its own bar.
|
||||||
/// All three numbers are editable `type="number"` inputs.
|
/// All three numbers are editable `type="number"` inputs; AC sits
|
||||||
|
/// next to them and is set manually.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn HitPoints () -> impl IntoView {
|
pub fn HitPoints () -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
@ -11,6 +12,7 @@ pub fn HitPoints () -> impl IntoView {
|
|||||||
let hp = player.hp();
|
let hp = player.hp();
|
||||||
let max_hp = player.max_hp();
|
let max_hp = player.max_hp();
|
||||||
let temp_hp = player.temp_hp();
|
let temp_hp = player.temp_hp();
|
||||||
|
let ac = player.ac();
|
||||||
|
|
||||||
// All three inputs parse via `utils::adjust_checked`; invalid text
|
// All three inputs parse via `utils::adjust_checked`; invalid text
|
||||||
// restores the previous value.
|
// restores the previous value.
|
||||||
@ -26,13 +28,16 @@ pub fn HitPoints () -> impl IntoView {
|
|||||||
utils::adjust_checked(ev, temp_hp);
|
utils::adjust_checked(ev, temp_hp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let adjust_ac = move |ev: Targeted<Event, HtmlInputElement>| {
|
||||||
|
utils::adjust_checked(ev, ac);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
let pc_hp = move |a: u16, b: u16| -> String {
|
let pc_hp = move |a: u16, b: u16| -> String {
|
||||||
format!("width: {}%", utils::filled_pc(a, b).min(100.))
|
format!("width: {}%", utils::filled_pc(a, b).min(100.))
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<h6>hp/оз:</h6>
|
<h6>"hp/оз, ac/КД:"</h6>
|
||||||
<div class="hp">
|
<div class="hp">
|
||||||
<div class="perm">
|
<div class="perm">
|
||||||
<div style=move || pc_hp(hp.get(), max_hp.get()) class="bar"></div>
|
<div style=move || pc_hp(hp.get(), max_hp.get()) class="bar"></div>
|
||||||
@ -48,6 +53,10 @@ pub fn HitPoints () -> impl IntoView {
|
|||||||
<input min=0 title="Временные хитпойнты" id="temp_hp" type="number" class="header-input" on:input:target=move |e| adjust_temp_hp(e) prop:value=move || temp_hp.get() />
|
<input min=0 title="Временные хитпойнты" id="temp_hp" type="number" class="header-input" on:input:target=move |e| adjust_temp_hp(e) prop:value=move || temp_hp.get() />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ac">
|
||||||
|
<span>"КД:"</span>
|
||||||
|
<input min=0 title="Класс доспеха (вручную)" id="ac" type="number" class="header-input" on:input:target=move |e| adjust_ac(e) prop:value=move || ac.get() />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,9 +30,10 @@ impl Dashboard {
|
|||||||
temp_hp: 12,
|
temp_hp: 12,
|
||||||
hp: 1,
|
hp: 1,
|
||||||
max_hp: 61,
|
max_hp: 61,
|
||||||
|
ac: 16,
|
||||||
balance: Balance { platinum: 12, gold: 200, electrum: 1, silver: 101, copper: 129021021 },
|
balance: Balance { platinum: 12, gold: 200, electrum: 1, silver: 101, copper: 129021021 },
|
||||||
spell_slots: SpellSlots(core::array::from_fn(|i| SpellSlotLevel { used: 1, total: 9 - i as u8 })),
|
spell_slots: SpellSlots(core::array::from_fn(|i| SpellSlotLevel { used: 1, total: 9 - i as u8 })),
|
||||||
attunements: Attunements([const { None }; 3]),
|
attunements: Attunements(vec![None; 3]),
|
||||||
hit_dice: HitDice { used: 1, kind: 20 },
|
hit_dice: HitDice { used: 1, kind: 20 },
|
||||||
death_save_throws: DeathSaveThrows { failed: 1, succeeded: 3 },
|
death_save_throws: DeathSaveThrows { failed: 1, succeeded: 3 },
|
||||||
prepared: generate_prepared_spells_list(),
|
prepared: generate_prepared_spells_list(),
|
||||||
|
|||||||
@ -101,10 +101,11 @@ impl Dashboard {
|
|||||||
temp_hp: 0,
|
temp_hp: 0,
|
||||||
hp: 0,
|
hp: 0,
|
||||||
max_hp: 0,
|
max_hp: 0,
|
||||||
|
ac: 0,
|
||||||
balance: Balance::default(),
|
balance: Balance::default(),
|
||||||
spell_slots: SpellSlots (core::array::from_fn(|_| SpellSlotLevel { used: 0, total: 0 })),
|
spell_slots: SpellSlots (core::array::from_fn(|_| SpellSlotLevel { used: 0, total: 0 })),
|
||||||
inventory: Inventory::default(),
|
inventory: Inventory::default(),
|
||||||
attunements: Attunements (core::array::from_fn(|_| None)),
|
attunements: Attunements (vec![None; 3]),
|
||||||
prepared: PreparedSpells (Vec::new()),
|
prepared: PreparedSpells (Vec::new()),
|
||||||
hit_dice: HitDice { used: 0, kind: 8 },
|
hit_dice: HitDice { used: 0, kind: 8 },
|
||||||
death_save_throws: DeathSaveThrows { failed: 0, succeeded: 0 },
|
death_save_throws: DeathSaveThrows { failed: 0, succeeded: 0 },
|
||||||
@ -134,6 +135,10 @@ pub struct PlayerData {
|
|||||||
pub temp_hp: u16,
|
pub temp_hp: u16,
|
||||||
pub hp: u16,
|
pub hp: u16,
|
||||||
pub max_hp: u16,
|
pub max_hp: u16,
|
||||||
|
/// Armor class; set manually by the player (no derived calculation
|
||||||
|
/// yet). Missing in old saved JSON, so default to 0.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ac: u8,
|
||||||
pub balance: Balance,
|
pub balance: Balance,
|
||||||
pub spell_slots: SpellSlots,
|
pub spell_slots: SpellSlots,
|
||||||
pub inventory: Inventory,
|
pub inventory: Inventory,
|
||||||
@ -248,9 +253,10 @@ pub struct SpellSlotLevel {
|
|||||||
pub total: u8,
|
pub total: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Three attunement slots (D&D rules allow three attuned items).
|
/// Attunement slots (D&D rules allow three attuned items; the UI can
|
||||||
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
|
/// add more slots for custom campaigns).
|
||||||
pub struct Attunements (pub [Option<String>; 3]);
|
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Attunements (pub Vec<Option<String>>);
|
||||||
|
|
||||||
|
|
||||||
/// The spell list the character has prepared (or is learning).
|
/// The spell list the character has prepared (or is learning).
|
||||||
|
|||||||
@ -8,39 +8,8 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 18px;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 92px;
|
|
||||||
height: 92px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 0 0 2px crimson;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-name {
|
|
||||||
font-size: 1.5em;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-class, .player-level, .player-xp {
|
|
||||||
font-weight: 300;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.prepared-spells, .attunements {
|
.prepared-spells, .attunements {
|
||||||
h6 {
|
h6 {
|
||||||
text-transform: lowercase;
|
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@ -100,9 +69,7 @@
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.prepared-spells {
|
|
||||||
.add-slot {
|
.add-slot {
|
||||||
@include mixins.button;
|
@include mixins.button;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|||||||
@ -134,6 +134,14 @@ aside {
|
|||||||
.val {
|
.val {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ac {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 1px solid rgba(white, 0.15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hit-dice {
|
.hit-dice {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user