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:
yaroslav k 2026-08-16 13:07:07 +03:00
parent 6d0846d160
commit 294bf430fd
10 changed files with 47 additions and 97 deletions

View File

@ -12,7 +12,7 @@ pub fn Attunements () -> impl IntoView {
view! {
<div class="attunements">
<h6>"attunements/слоты привязки:"</h6>
<h6>"Атюнменты:"</h6>
<For
// Same state-in-key convention as prepared spells, so a
// slot whose value changed re-renders.
@ -22,6 +22,13 @@ pub fn Attunements () -> impl IntoView {
>
{slot_row(attunements, idx, slot)}
</For>
<button
class="add-slot"
title="Добавить слот привязки"
on:click=move |_| attunements.update(|a| a.0.push(None))
>
"+ слот"
</button>
</div>
}
}

View File

@ -1,8 +1,7 @@
//! Character page blocks (route `/`): player card, prepared spells and
//! attunements. The sidebar already carries the detailed editors (names,
//! class/level/XP, HP, hit dice, death saves, spell slots); this module
//! renders the page-level view of that data.
//! Character page blocks (route `/`): prepared spells and attunements.
//! The sidebar carries the character editors (names, class/level/XP,
//! HP, hit dice, death saves, spell slots); this module renders the
//! page-level view.
pub mod attunements;
pub mod player_card;
pub mod prepared_spells;

View File

@ -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>
}
}

View File

@ -16,7 +16,7 @@ pub fn PreparedSpells () -> impl IntoView {
view! {
<div class="prepared-spells">
<h6>"prepared spells/подготовленные заклинания:"</h6>
<h6>"Заготовленные заклинания:"</h6>
<For
// The key carries the slot state, so a slot whose state
// changed re-renders (same convention as sidebar pips).

View File

@ -24,14 +24,12 @@ pub mod numput;
pub mod item;
pub mod modal;
/// Character page (route `/`): player card, prepared spells and
/// attunements (ROADMAP phase 2). The sidebar carries the detailed
/// editors; the HP block with its Numput-style inputs lives there too.
/// Character page (route `/`): prepared spells and attunements
/// (ROADMAP phase 2). The sidebar carries the character editors.
#[component]
pub fn Character () -> impl IntoView {
view! {
<div class="character-page">
<character::player_card::PlayerCard />
<character::prepared_spells::PreparedSpells />
<character::attunements::Attunements />
</div>

View File

@ -2,7 +2,8 @@ use crate::prelude::*;
use leptos::prelude::*;
/// 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]
pub fn HitPoints () -> impl IntoView {
let state = expect_context::<Context>();
@ -11,6 +12,7 @@ pub fn HitPoints () -> impl IntoView {
let hp = player.hp();
let max_hp = player.max_hp();
let temp_hp = player.temp_hp();
let ac = player.ac();
// All three inputs parse via `utils::adjust_checked`; invalid text
// restores the previous value.
@ -26,13 +28,16 @@ pub fn HitPoints () -> impl IntoView {
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 {
format!("width: {}%", utils::filled_pc(a, b).min(100.))
};
view! {
<h6>hp/оз:</h6>
<h6>"hp/оз, ac/КД:"</h6>
<div class="hp">
<div class="perm">
<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() />
</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>
}
}

View File

@ -30,9 +30,10 @@ impl Dashboard {
temp_hp: 12,
hp: 1,
max_hp: 61,
ac: 16,
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 })),
attunements: Attunements([const { None }; 3]),
attunements: Attunements(vec![None; 3]),
hit_dice: HitDice { used: 1, kind: 20 },
death_save_throws: DeathSaveThrows { failed: 1, succeeded: 3 },
prepared: generate_prepared_spells_list(),

View File

@ -101,10 +101,11 @@ impl Dashboard {
temp_hp: 0,
hp: 0,
max_hp: 0,
ac: 0,
balance: Balance::default(),
spell_slots: SpellSlots (core::array::from_fn(|_| SpellSlotLevel { used: 0, total: 0 })),
inventory: Inventory::default(),
attunements: Attunements (core::array::from_fn(|_| None)),
attunements: Attunements (vec![None; 3]),
prepared: PreparedSpells (Vec::new()),
hit_dice: HitDice { used: 0, kind: 8 },
death_save_throws: DeathSaveThrows { failed: 0, succeeded: 0 },
@ -134,6 +135,10 @@ pub struct PlayerData {
pub temp_hp: u16,
pub 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 spell_slots: SpellSlots,
pub inventory: Inventory,
@ -248,9 +253,10 @@ pub struct SpellSlotLevel {
pub total: u8,
}
/// Three attunement slots (D&D rules allow three attuned items).
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct Attunements (pub [Option<String>; 3]);
/// Attunement slots (D&D rules allow three attuned items; the UI can
/// add more slots for custom campaigns).
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct Attunements (pub Vec<Option<String>>);
/// The spell list the character has prepared (or is learning).

View File

@ -8,39 +8,8 @@
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 {
h6 {
text-transform: lowercase;
font-size: 1.1em;
margin: 0 0 10px;
font-weight: 900;
@ -100,9 +69,7 @@
font-weight: 900;
min-width: 200px;
}
}
.prepared-spells {
.add-slot {
@include mixins.button;
margin-top: 10px;

View File

@ -134,6 +134,14 @@ aside {
.val {
z-index: 2;
}
.ac {
display: flex;
align-items: center;
gap: 6px;
padding-left: 10px;
border-left: 1px solid rgba(white, 0.15);
}
}
.hit-dice {