Compare commits
1 commit
784dc1c4f7
...
5d32b7082d
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d32b7082d |
8 changed files with 120 additions and 249 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1397,7 +1397,6 @@ dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"gloo-storage",
|
"gloo-storage",
|
||||||
"gloo-timers",
|
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_i18n",
|
"leptos_i18n",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
|
|
@ -1405,7 +1404,6 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"trictrac-store",
|
"trictrac-store",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,9 @@ serde_json = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
gloo-storage = "0.3"
|
gloo-storage = "0.3"
|
||||||
gloo-timers = "0.3"
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
|
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
|
||||||
# Must be a direct dependency (not just transitive) for the feature to take effect.
|
# Must be a direct dependency (not just transitive) for the feature to take effect.
|
||||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
web-sys = { version = "0.3", features = [
|
|
||||||
"Document",
|
|
||||||
"Element",
|
|
||||||
"HtmlElement",
|
|
||||||
"CssStyleDeclaration",
|
|
||||||
"DomTokenList",
|
|
||||||
] }
|
|
||||||
|
|
|
||||||
|
|
@ -455,20 +455,20 @@ body {
|
||||||
|
|
||||||
/* ── Die face (SVG) ─────────────────────────────────────────────────── */
|
/* ── Die face (SVG) ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* §5a — vigorous tumble: die bounces in from a random rotation */
|
/* §5a — vigorous tumble: simulates the die being shaken and thrown (§5b cup tips first) */
|
||||||
@keyframes die-tumble {
|
@keyframes die-tumble {
|
||||||
0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; }
|
0% { transform: rotate(-30deg) scale(0.55); opacity: 0; }
|
||||||
25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; }
|
20% { transform: rotate(14deg) scale(1.18); opacity: 1; }
|
||||||
45% { transform: rotate(-10deg) scale(0.91); }
|
40% { transform: rotate(-8deg) scale(0.93); }
|
||||||
62% { transform: rotate(6deg) scale(1.06); }
|
60% { transform: rotate(5deg) scale(1.05); }
|
||||||
76% { transform: rotate(-3deg) scale(0.98); }
|
78% { transform: rotate(-3deg) scale(0.99); }
|
||||||
88% { transform: rotate(1.5deg) scale(1.01); }
|
92% { transform: rotate(1deg) scale(1.01); }
|
||||||
100% { transform: rotate(0deg) scale(1); opacity: 1; }
|
100% { transform: rotate(0deg) scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.die-face {
|
.die-face {
|
||||||
filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3));
|
filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3));
|
||||||
animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
animation: die-tumble 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) 0.25s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.die-face rect {
|
.die-face rect {
|
||||||
|
|
@ -482,11 +482,47 @@ body {
|
||||||
transition: fill 0.18s;
|
transition: fill 0.18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bar die slot — centered in the board bar */
|
/* ── Dice cup (§5b) — lives in the center board bar ────────────────── */
|
||||||
.bar-die-slot {
|
.bar-cup-slot {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cup shape: wider opening at top, narrower base (like a cornet/tumbler) */
|
||||||
|
.bar-cup {
|
||||||
|
width: 36px;
|
||||||
|
height: 42px;
|
||||||
|
background: linear-gradient(160deg, #4a2810 0%, #1e0c04 100%);
|
||||||
|
clip-path: polygon(0% 0%, 100% 0%, 88% 100%, 12% 100%);
|
||||||
|
box-shadow: inset 0 -2px 4px rgba(0,0,0,0.5), inset 2px 0 3px rgba(255,255,255,0.04);
|
||||||
|
transform-origin: top center;
|
||||||
|
transition: transform 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
/* Gilt rim at the cup opening */
|
||||||
|
.bar-cup::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(200,164,72,0.35), transparent);
|
||||||
|
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
|
||||||
|
}
|
||||||
|
/* Cup poured: tips 105° clockwise (opening rotates downward-right, dice fall out) */
|
||||||
|
.bar-cup.cup-poured {
|
||||||
|
transform: rotate(105deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die slot: appears below the cup after it tips; animation delayed to sync with cup */
|
||||||
|
@keyframes die-fall-in {
|
||||||
|
from { opacity: 0; transform: translateY(-12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.bar-die-slot {
|
||||||
|
animation: die-fall-in 0.22s ease-out 0.18s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Double glow (§5c) */
|
/* Double glow (§5c) */
|
||||||
|
|
@ -876,46 +912,23 @@ body {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
border: 2px solid var(--checker-ring);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 5px rgba(255,255,255,0.35),
|
||||||
|
inset 0 -1px 3px rgba(0,0,0,0.2),
|
||||||
|
0 2px 4px rgba(0,0,0,0.35);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checker + .checker { margin-top: -4px; }
|
.checker + .checker { margin-top: -4px; }
|
||||||
|
|
||||||
.checker.white {
|
.checker.white {
|
||||||
background-image:
|
background: radial-gradient(circle at 38% 32%, #ffffff, var(--checker-white) 65%, #d8cdb0);
|
||||||
radial-gradient(ellipse 50% 35% at 36% 30%,
|
|
||||||
rgba(255,255,255,0.65) 0%, transparent 100%),
|
|
||||||
radial-gradient(circle,
|
|
||||||
transparent 68%, rgba(160,130,70,0.22) 68.5%,
|
|
||||||
rgba(160,130,70,0.22) 71.5%, transparent 72%),
|
|
||||||
radial-gradient(circle,
|
|
||||||
transparent 43%, rgba(160,130,70,0.17) 43.5%,
|
|
||||||
rgba(160,130,70,0.17) 46.5%, transparent 47%),
|
|
||||||
radial-gradient(circle at 38% 32%,
|
|
||||||
#ffffff 0%, var(--checker-white) 52%, #c0b288 100%);
|
|
||||||
border: 1.8px solid var(--checker-ring);
|
|
||||||
box-shadow:
|
|
||||||
0 2px 6px rgba(0,0,0,0.4),
|
|
||||||
inset 0 -1px 3px rgba(0,0,0,0.15);
|
|
||||||
color: #443322;
|
color: #443322;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checker.black {
|
.checker.black {
|
||||||
background-image:
|
background: radial-gradient(circle at 38% 32%, #444444, #1c1008 65%, var(--checker-black));
|
||||||
radial-gradient(ellipse 40% 28% at 36% 30%,
|
|
||||||
rgba(110,65,30,0.38) 0%, transparent 100%),
|
|
||||||
radial-gradient(circle,
|
|
||||||
transparent 68%, rgba(200,164,72,0.18) 68.5%,
|
|
||||||
rgba(200,164,72,0.18) 71.5%, transparent 72%),
|
|
||||||
radial-gradient(circle,
|
|
||||||
transparent 43%, rgba(200,164,72,0.13) 43.5%,
|
|
||||||
rgba(200,164,72,0.13) 46.5%, transparent 47%),
|
|
||||||
radial-gradient(circle at 38% 32%,
|
|
||||||
#4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%);
|
|
||||||
border: 1.8px solid var(--checker-ring);
|
|
||||||
box-shadow:
|
|
||||||
0 2px 6px rgba(0,0,0,0.55),
|
|
||||||
inset 0 -1px 3px rgba(0,0,0,0.4);
|
|
||||||
color: #c8b898;
|
color: #c8b898;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -999,24 +1012,6 @@ body {
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── §4a — Checker slide animation ─────────────────────────────────── */
|
|
||||||
@keyframes checker-slide-in {
|
|
||||||
from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); }
|
|
||||||
to { transform: none; }
|
|
||||||
}
|
|
||||||
.checker-stack.sliding {
|
|
||||||
animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
||||||
}
|
|
||||||
/* Lift the field that owns a sliding stack above its siblings so the
|
|
||||||
checker doesn't slide under adjacent fields (isolation:isolate traps
|
|
||||||
z-index within each field's stacking context, so we must elevate the
|
|
||||||
field itself). */
|
|
||||||
.field:has(.checker-stack.sliding) {
|
|
||||||
isolation: auto;
|
|
||||||
z-index: 10;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Checker lift on selected field (§4b) ───────────────────────────── */
|
/* ── Checker lift on selected field (§4b) ───────────────────────────── */
|
||||||
.field.selected .checker-stack {
|
.field.selected .checker-stack {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-5px);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ use crate::i18n::I18nContextProvider;
|
||||||
use crate::trictrac::backend::TrictracBackend;
|
use crate::trictrac::backend::TrictracBackend;
|
||||||
use crate::trictrac::bot_local::bot_decide;
|
use crate::trictrac::bot_local::bot_decide;
|
||||||
use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState};
|
use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState};
|
||||||
use trictrac_store::CheckerMove;
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
|
@ -36,8 +35,6 @@ pub struct GameUiState {
|
||||||
/// Points scored by this player in the transition to this state (if any).
|
/// Points scored by this player in the transition to this state (if any).
|
||||||
pub my_scored_event: Option<ScoredEvent>,
|
pub my_scored_event: Option<ScoredEvent>,
|
||||||
pub opp_scored_event: Option<ScoredEvent>,
|
pub opp_scored_event: Option<ScoredEvent>,
|
||||||
/// Checker moves to animate on this render. None when board is unchanged.
|
|
||||||
pub last_moves: Option<(CheckerMove, CheckerMove)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reason the UI is paused waiting for the player to click Continue.
|
/// Reason the UI is paused waiting for the player to click Continue.
|
||||||
|
|
@ -275,7 +272,6 @@ pub fn App() -> impl IntoView {
|
||||||
pause_reason: None,
|
pause_reason: None,
|
||||||
my_scored_event: None,
|
my_scored_event: None,
|
||||||
opp_scored_event: None,
|
opp_scored_event: None,
|
||||||
last_moves: compute_last_moves(&prev_vs, &vs),
|
|
||||||
},
|
},
|
||||||
pending,
|
pending,
|
||||||
screen,
|
screen,
|
||||||
|
|
@ -342,7 +338,6 @@ async fn run_local_bot_game(
|
||||||
pause_reason: None,
|
pause_reason: None,
|
||||||
my_scored_event: None,
|
my_scored_event: None,
|
||||||
opp_scored_event: None,
|
opp_scored_event: None,
|
||||||
last_moves: None,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -366,7 +361,6 @@ async fn run_local_bot_game(
|
||||||
pause_reason: None,
|
pause_reason: None,
|
||||||
my_scored_event: scored,
|
my_scored_event: scored,
|
||||||
opp_scored_event: opp_scored,
|
opp_scored_event: opp_scored,
|
||||||
last_moves: compute_last_moves(&prev_vs, &vs),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Some(NetCommand::PlayVsBot) => return true,
|
Some(NetCommand::PlayVsBot) => return true,
|
||||||
|
|
@ -395,7 +389,6 @@ async fn run_local_bot_game(
|
||||||
pause_reason: None,
|
pause_reason: None,
|
||||||
my_scored_event: None,
|
my_scored_event: None,
|
||||||
opp_scored_event: None,
|
opp_scored_event: None,
|
||||||
last_moves: compute_last_moves(&prev_vs, &vs),
|
|
||||||
},
|
},
|
||||||
pending,
|
pending,
|
||||||
screen,
|
screen,
|
||||||
|
|
@ -406,22 +399,6 @@ async fn run_local_bot_game(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the checker moves to animate when the board changed between two ViewStates.
|
|
||||||
/// Returns `None` when the board is unchanged or no real moves were recorded.
|
|
||||||
fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove, CheckerMove)> {
|
|
||||||
if prev.board == next.board {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (m1, m2) = next.dice_moves;
|
|
||||||
if m1 == CheckerMove::default() && m2 == CheckerMove::default() {
|
|
||||||
// Relies on the engine invariant: dice_moves is updated atomically with the board
|
|
||||||
// change in the Move event handler. Any future engine path that mutates the board
|
|
||||||
// without setting dice_moves would bypass this guard and replay stale animation.
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some((m1, m2))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Computes a scoring event for `player_id` by comparing the previous and next
|
/// Computes a scoring event for `player_id` by comparing the previous and next
|
||||||
/// ViewState. Returns `None` when no points changed for that player.
|
/// ViewState. Returns `None` when no points changed for that player.
|
||||||
fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> {
|
fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> {
|
||||||
|
|
@ -494,9 +471,7 @@ fn push_or_show(
|
||||||
..new_state.clone()
|
..new_state.clone()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Animation belongs to the buffered confirmation step; clear it on the
|
screen.set(Screen::Playing(new_state));
|
||||||
// fallback live state so it doesn't fire again after the queue drains.
|
|
||||||
screen.set(Screen::Playing(GameUiState { last_moves: None, ..new_state }));
|
|
||||||
} else {
|
} else {
|
||||||
// No pause: show scoring directly on the live state.
|
// No pause: show scoring directly on the live state.
|
||||||
screen.set(Screen::Playing(GameUiState {
|
screen.set(Screen::Playing(GameUiState {
|
||||||
|
|
@ -553,7 +528,6 @@ mod tests {
|
||||||
scores: [score(), score()],
|
scores: [score(), score()],
|
||||||
dice,
|
dice,
|
||||||
dice_jans: Vec::new(),
|
dice_jans: Vec::new(),
|
||||||
dice_moves: (CheckerMove::default(), CheckerMove::default()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use trictrac_store::CheckerMove;
|
use trictrac_store::CheckerMove;
|
||||||
|
|
||||||
use super::die::Die;
|
|
||||||
use crate::trictrac::types::{SerTurnStage, ViewState};
|
use crate::trictrac::types::{SerTurnStage, ViewState};
|
||||||
|
use super::die::Die;
|
||||||
|
|
||||||
/// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
|
/// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
|
||||||
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
|
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
|
||||||
|
|
@ -20,11 +20,7 @@ const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13];
|
||||||
/// Returns true when `field_num` is the rest corner for this perspective.
|
/// Returns true when `field_num` is the rest corner for this perspective.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn is_rest_corner(field_num: u8, is_white: bool) -> bool {
|
fn is_rest_corner(field_num: u8, is_white: bool) -> bool {
|
||||||
if is_white {
|
if is_white { field_num == 12 } else { field_num == 13 }
|
||||||
field_num == 12
|
|
||||||
} else {
|
|
||||||
field_num == 13
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zone CSS class for a field number (field coordinates are always White's 1-24).
|
/// Zone CSS class for a field number (field coordinates are always White's 1-24).
|
||||||
|
|
@ -43,20 +39,11 @@ fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
|
||||||
let mut d0 = false;
|
let mut d0 = false;
|
||||||
let mut d1 = false;
|
let mut d1 = false;
|
||||||
for &(from, to) in staged {
|
for &(from, to) in staged {
|
||||||
let dist = if from < to {
|
let dist = if from < to { to.saturating_sub(from) } else { from.saturating_sub(to) };
|
||||||
to.saturating_sub(from)
|
if !d0 && dist == dice.0 { d0 = true; }
|
||||||
} else {
|
else if !d1 && dist == dice.1 { d1 = true; }
|
||||||
from.saturating_sub(to)
|
else if !d0 { d0 = true; }
|
||||||
};
|
else { d1 = true; }
|
||||||
if !d0 && dist == dice.0 {
|
|
||||||
d0 = true;
|
|
||||||
} else if !d1 && dist == dice.1 {
|
|
||||||
d1 = true;
|
|
||||||
} else if !d0 {
|
|
||||||
d0 = true;
|
|
||||||
} else {
|
|
||||||
d1 = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(d0, d1)
|
(d0, d1)
|
||||||
}
|
}
|
||||||
|
|
@ -85,8 +72,7 @@ fn displayed_value(
|
||||||
/// Fields whose checkers may be selected as the next origin given already-staged moves.
|
/// Fields whose checkers may be selected as the next origin given already-staged moves.
|
||||||
fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
|
fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
|
||||||
let mut v: Vec<u8> = match staged.len() {
|
let mut v: Vec<u8> = match staged.len() {
|
||||||
0 => seqs
|
0 => seqs.iter()
|
||||||
.iter()
|
|
||||||
.map(|(m1, _)| m1.get_from() as u8)
|
.map(|(m1, _)| m1.get_from() as u8)
|
||||||
.filter(|&f| f != 0)
|
.filter(|&f| f != 0)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|
@ -132,31 +118,13 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
|
||||||
};
|
};
|
||||||
// Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i
|
// Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i
|
||||||
// Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i
|
// Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i
|
||||||
let x = if right {
|
let x = if right { 480.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 };
|
||||||
480.0 + qi as f32 * 62.0
|
|
||||||
} else {
|
|
||||||
34.0 + qi as f32 * 62.0
|
|
||||||
};
|
|
||||||
// Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
|
// Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
|
||||||
// (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360)
|
// (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360)
|
||||||
let y = if top { 30.0 } else { 358.0 };
|
let y = if top { 30.0 } else { 358.0 };
|
||||||
Some((x, y))
|
Some((x, y))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn apply_slide_animation(to_field: usize, dx: f32, dy: f32) {
|
|
||||||
use web_sys::wasm_bindgen::JsCast;
|
|
||||||
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
|
|
||||||
let selector = format!("#field-{} .checker-stack", to_field);
|
|
||||||
let Ok(Some(el)) = doc.query_selector(&selector) else { return };
|
|
||||||
let Ok(html) = el.dyn_into::<web_sys::HtmlElement>() else { return };
|
|
||||||
let style = html.style();
|
|
||||||
style.set_property("--slide-dx", &format!("{:.1}px", dx)).ok();
|
|
||||||
style.set_property("--slide-dy", &format!("{:.1}px", dy)).ok();
|
|
||||||
html.class_list().add_1("sliding").ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
|
/// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
|
||||||
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
|
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
|
||||||
let (x1, y1) = fp;
|
let (x1, y1) = fp;
|
||||||
|
|
@ -185,21 +153,15 @@ fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
|
||||||
let bary = y2 - ny * ah;
|
let bary = y2 - ny * ah;
|
||||||
let pts = format!(
|
let pts = format!(
|
||||||
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||||
x2,
|
x2, y2,
|
||||||
y2,
|
bx + px * aw, bary + py * aw,
|
||||||
bx + px * aw,
|
bx - px * aw, bary - py * aw,
|
||||||
bary + py * aw,
|
|
||||||
bx - px * aw,
|
|
||||||
bary - py * aw,
|
|
||||||
);
|
);
|
||||||
let shadow_pts = format!(
|
let shadow_pts = format!(
|
||||||
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||||
x2,
|
x2, y2,
|
||||||
y2,
|
bx + px * (aw + 1.5), bary + py * (aw + 1.5),
|
||||||
bx + px * (aw + 1.5),
|
bx - px * (aw + 1.5), bary - py * (aw + 1.5),
|
||||||
bary + py * (aw + 1.5),
|
|
||||||
bx - px * (aw + 1.5),
|
|
||||||
bary - py * (aw + 1.5),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
|
@ -225,14 +187,9 @@ fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
|
||||||
|
|
||||||
/// Valid destinations for a selected origin given already-staged moves.
|
/// Valid destinations for a selected origin given already-staged moves.
|
||||||
/// May include 0 (exit); callers handle that case.
|
/// May include 0 (exit); callers handle that case.
|
||||||
fn valid_dests_for(
|
fn valid_dests_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)], origin: u8) -> Vec<u8> {
|
||||||
seqs: &[(CheckerMove, CheckerMove)],
|
|
||||||
staged: &[(u8, u8)],
|
|
||||||
origin: u8,
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let mut v: Vec<u8> = match staged.len() {
|
let mut v: Vec<u8> = match staged.len() {
|
||||||
0 => seqs
|
0 => seqs.iter()
|
||||||
.iter()
|
|
||||||
.filter(|(m1, _)| m1.get_from() as u8 == origin)
|
.filter(|(m1, _)| m1.get_from() as u8 == origin)
|
||||||
.map(|(m1, _)| m1.get_to() as u8)
|
.map(|(m1, _)| m1.get_to() as u8)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|
@ -265,17 +222,11 @@ pub fn Board(
|
||||||
/// All valid two-move sequences for this turn (empty when not in move stage).
|
/// All valid two-move sequences for this turn (empty when not in move stage).
|
||||||
valid_sequences: Vec<(CheckerMove, CheckerMove)>,
|
valid_sequences: Vec<(CheckerMove, CheckerMove)>,
|
||||||
/// Dice to display in the center bars; None means dice not yet rolled (cups shown upright).
|
/// Dice to display in the center bars; None means dice not yet rolled (cups shown upright).
|
||||||
#[prop(default = None)]
|
#[prop(default = None)] bar_dice: Option<(u8, u8)>,
|
||||||
bar_dice: Option<(u8, u8)>,
|
|
||||||
/// Whether we're in the move stage (determines used/unused die appearance).
|
/// Whether we're in the move stage (determines used/unused die appearance).
|
||||||
#[prop(default = false)]
|
#[prop(default = false)] bar_is_move: bool,
|
||||||
bar_is_move: bool,
|
|
||||||
/// Whether the dice are a double (golden glow).
|
/// Whether the dice are a double (golden glow).
|
||||||
#[prop(default = false)]
|
#[prop(default = false)] bar_is_double: bool,
|
||||||
bar_is_double: bool,
|
|
||||||
/// Checker moves to animate on mount (None when board unchanged).
|
|
||||||
#[prop(default = None)]
|
|
||||||
last_moves: Option<(CheckerMove, CheckerMove)>,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let board = view_state.board;
|
let board = view_state.board;
|
||||||
let is_move_stage = view_state.active_mp_player == Some(player_id)
|
let is_move_stage = view_state.active_mp_player == Some(player_id)
|
||||||
|
|
@ -319,7 +270,6 @@ pub fn Board(
|
||||||
};
|
};
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
id={format!("field-{field_num}")}
|
|
||||||
title=corner_title
|
title=corner_title
|
||||||
class=move || {
|
class=move || {
|
||||||
let staged = staged_moves.get();
|
let staged = staged_moves.get();
|
||||||
|
|
@ -447,16 +397,16 @@ pub fn Board(
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Bar content: die in the center bar (die_idx 0 = top bar, 1 = bottom bar) ──
|
// ── Bar content: cup (always) + die (when bar_dice is Some) ──────────────
|
||||||
|
// die_idx 0 = top bar, 1 = bottom bar.
|
||||||
let bar_content = move |die_idx: u8| -> AnyView {
|
let bar_content = move |die_idx: u8| -> AnyView {
|
||||||
match bar_dice {
|
let poured = bar_dice.is_some();
|
||||||
None => view! { <div class="bar-die-slot"></div> }.into_any(),
|
let cup_cls = if poured { "bar-cup cup-poured" } else { "bar-cup" };
|
||||||
Some(dice_vals) => {
|
view! {
|
||||||
let die_val = if die_idx == 0 {
|
<div class="bar-cup-slot">
|
||||||
dice_vals.0
|
<div class=cup_cls></div>
|
||||||
} else {
|
{bar_dice.map(|dice_vals| {
|
||||||
dice_vals.1
|
let die_val = if die_idx == 0 { dice_vals.0 } else { dice_vals.1 };
|
||||||
};
|
|
||||||
view! {
|
view! {
|
||||||
<div class="bar-die-slot">
|
<div class="bar-die-slot">
|
||||||
{move || {
|
{move || {
|
||||||
|
|
@ -471,43 +421,12 @@ pub fn Board(
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// §4a — animate checker moves. Deferred to a macrotask so Leptos has time
|
|
||||||
// to mount the Board's field divs before we query the DOM.
|
|
||||||
Effect::new(move |_| {
|
|
||||||
let Some((m1, m2)) = last_moves else { return };
|
|
||||||
|
|
||||||
// Collect the (to_field, dx, dy) pairs we need before moving into spawn_local.
|
|
||||||
let mut animations: Vec<(usize, f32, f32)> = Vec::new();
|
|
||||||
for m in [m1, m2] {
|
|
||||||
if m.get_from() == 0 && m.get_to() == 0 { continue; }
|
|
||||||
let Some((fx, fy)) = field_center(m.get_from(), is_white) else { continue };
|
|
||||||
let Some((tx, ty)) = field_center(m.get_to(), is_white) else { continue };
|
|
||||||
let dx = fx - tx;
|
|
||||||
let dy = fy - ty;
|
|
||||||
if dx.abs() < 1.0 && dy.abs() < 1.0 { continue; }
|
|
||||||
animations.push((m.get_to(), dx, dy));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
if let Some((to_field, dx, dy)) = animations.first().copied() {
|
|
||||||
gloo_timers::callback::Timeout::new(0, move || {
|
|
||||||
apply_slide_animation(to_field, dx, dy);
|
|
||||||
}).forget();
|
|
||||||
}
|
|
||||||
if let Some((to_field, dx, dy)) = animations.get(1).copied() {
|
|
||||||
gloo_timers::callback::Timeout::new(300, move || {
|
|
||||||
apply_slide_animation(to_field, dx, dy);
|
|
||||||
}).forget();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let (tl, tr, bl, br) = if is_white {
|
let (tl, tr, bl, br) = if is_white {
|
||||||
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
|
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
|
|
||||||
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
|
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
|
||||||
|
|
||||||
let last_moves = state.last_moves;
|
|
||||||
|
|
||||||
// ── Capture for closures ───────────────────────────────────────────────────
|
// ── Capture for closures ───────────────────────────────────────────────────
|
||||||
let stage = vs.stage.clone();
|
let stage = vs.stage.clone();
|
||||||
let turn_stage = vs.turn_stage.clone();
|
let turn_stage = vs.turn_stage.clone();
|
||||||
|
|
@ -216,7 +214,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
bar_dice=show_dice.then_some(dice)
|
bar_dice=show_dice.then_some(dice)
|
||||||
bar_is_move=is_move_stage
|
bar_is_move=is_move_stage
|
||||||
bar_is_double=is_double_dice
|
bar_is_double=is_double_dice
|
||||||
last_moves=last_moves
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// ── Side panel (scoring panels only) ─────────────────────────
|
// ── Side panel (scoring panels only) ─────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,12 @@ use super::score_panel::jan_label;
|
||||||
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||||
let jan = entry.jan;
|
let label = jan_label(&entry.jan);
|
||||||
let is_double = entry.is_double;
|
let double_tag = if entry.is_double {
|
||||||
|
t_string!(i18n, jan_double).to_owned()
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, jan_simple).to_owned()
|
||||||
|
};
|
||||||
let ways_tag = format!("×{}", entry.ways);
|
let ways_tag = format!("×{}", entry.ways);
|
||||||
let pts_str = format!("+{}", entry.total);
|
let pts_str = format!("+{}", entry.total);
|
||||||
let moves_hover = entry.moves.clone();
|
let moves_hover = entry.moves.clone();
|
||||||
|
|
@ -31,12 +35,8 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span class="jan-label">{move || jan_label(&jan)}</span>
|
<span class="jan-label">{label}</span>
|
||||||
<span class="jan-tag">{move || if is_double {
|
<span class="jan-tag">{double_tag}</span>
|
||||||
t_string!(i18n, jan_double).to_owned()
|
|
||||||
} else {
|
|
||||||
t_string!(i18n, jan_simple).to_owned()
|
|
||||||
}}</span>
|
|
||||||
<span class="jan-tag">{ways_tag}</span>
|
<span class="jan-tag">{ways_tag}</span>
|
||||||
<span class="jan-pts">{pts_str}</span>
|
<span class="jan-pts">{pts_str}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,6 @@ pub struct ViewState {
|
||||||
pub dice: (u8, u8),
|
pub dice: (u8, u8),
|
||||||
/// Jans (scoring events) triggered by the last dice roll.
|
/// Jans (scoring events) triggered by the last dice roll.
|
||||||
pub dice_jans: Vec<JanEntry>,
|
pub dice_jans: Vec<JanEntry>,
|
||||||
/// Last two checker moves played; default when no move has occurred yet.
|
|
||||||
pub dice_moves: (CheckerMove, CheckerMove),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One scoring event from a dice roll.
|
/// One scoring event from a dice roll.
|
||||||
|
|
@ -75,7 +73,6 @@ impl ViewState {
|
||||||
],
|
],
|
||||||
dice: (0, 0),
|
dice: (0, 0),
|
||||||
dice_jans: Vec::new(),
|
dice_jans: Vec::new(),
|
||||||
dice_moves: (CheckerMove::default(), CheckerMove::default()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +166,6 @@ impl ViewState {
|
||||||
scores: [score_for(host_store_id), score_for(guest_store_id)],
|
scores: [score_for(host_store_id), score_for(guest_store_id)],
|
||||||
dice: (gs.dice.values.0, gs.dice.values.1),
|
dice: (gs.dice.values.0, gs.dice.values.1),
|
||||||
dice_jans,
|
dice_jans,
|
||||||
dice_moves: gs.dice_moves,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue