diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 32677b8..bc6d5d7 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -1,87 +1,282 @@ +/* ── Google Fonts ───────────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); + +/* ── Design tokens ──────────────────────────────────────────────────── */ +:root { + --board-felt: #1d3d28; + --board-rail: #2a1508; + --field-ivory: #f0e6c8; + --field-burgundy: #7a1e2a; + --field-corner: #b8900a; + --checker-white: #f5edd8; + --checker-black: #1a0f06; + --checker-ring: #c8a448; + --ui-parchment: #f2e8d0; + --ui-parchment-dark: #e4d8b8; + --ui-ink: #2a1a08; + --ui-gold: #c8a448; + --ui-gold-dark: #8a6a28; + --ui-green-accent: #3a6b2a; + --ui-red-accent: #7a1e2a; + --font-display: 'Cormorant Garamond', Georgia, serif; + --font-ui: 'Jost', system-ui, sans-serif; +} + /* ── Reset & base ──────────────────────────────────────────────────── */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { - font-family: sans-serif; - background: #c8b084; + font-family: var(--font-ui); + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); display: flex; justify-content: center; padding: 1.5rem; min-height: 100vh; } -/* ── Login / Connecting screens ────────────────────────────────────── */ -.login-container { +.hidden { display: none !important; } + +/* ── Login card (§11) ───────────────────────────────────────────────── */ +.login-card { + width: 340px; + margin-top: 5vh; + border-radius: 8px; + overflow: hidden; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + background: var(--ui-parchment); +} + +/* Decorative header — row of triangular flèches like the actual board */ +.login-card-header { + height: 52px; + background: var(--board-felt); + position: relative; + overflow: hidden; +} + +.login-board-stripe { + position: absolute; + inset: 0; + /* Alternating burgundy/ivory triangles pointing down from the top */ + background: + repeating-linear-gradient( + 90deg, + var(--field-burgundy) 0, var(--field-burgundy) 50%, + var(--field-ivory) 50%, var(--field-ivory) 100% + ); + background-size: 34px 100%; + /* Clip into downward-pointing triangles */ + clip-path: polygon( + 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%, + 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%, + 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%, + 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%, + 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%, + 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%, + 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%, + 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%, + 97.06% 100%, 100% 0% + ); + opacity: 0.9; +} + +.login-card-body { display: flex; flex-direction: column; - gap: 0.75rem; - max-width: 320px; - margin-top: 4rem; + align-items: center; + gap: 0; + padding: 1.5rem 2rem 2rem; } -.login-container h1 { font-size: 2rem; text-align: center; margin-bottom: 0.5rem; } +.login-lang-switcher { + align-self: flex-end; + margin-bottom: 0.75rem; +} -input[type="text"] { - padding: 0.5rem 0.75rem; +/* Override lang-switcher colours for the parchment card */ +.login-card .lang-switcher button { + color: var(--ui-ink); + border-color: rgba(42,21,8,0.2); + opacity: 0.5; +} +.login-card .lang-switcher button.lang-active { + opacity: 1; + background: rgba(42,21,8,0.08); + border-color: rgba(42,21,8,0.35); +} + +.login-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.12em; + text-align: center; + line-height: 1; + margin-bottom: 0.3rem; +} + +.login-subtitle { + font-family: var(--font-display); + font-size: 0.85rem; + color: rgba(42,26,8,0.55); + text-align: center; + letter-spacing: 0.06em; + font-style: italic; + margin-bottom: 1.25rem; +} +.login-subtitle sup { + font-size: 0.65em; + vertical-align: super; +} + +.login-ornament { + color: var(--ui-gold); font-size: 1rem; - border: 1px solid #aaa; - border-radius: 4px; + opacity: 0.7; + margin-bottom: 1.25rem; + letter-spacing: 0.3em; + text-align: center; } -.error-msg { color: #c00; font-size: 0.9rem; } +.error-msg { + color: #c03030; + font-size: 0.85rem; + text-align: center; + margin-bottom: 0.5rem; +} -.connecting { font-size: 1.2rem; margin-top: 4rem; text-align: center; } +.login-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.4); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + margin-bottom: 1rem; +} +.login-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.2); +} -/* ── Buttons ────────────────────────────────────────────────────────── */ +.login-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; +} + +/* Login buttons styled as embossed wooden tiles */ +.login-btn { + width: 100%; + padding: 0.65rem 1rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border: none; + border-radius: 5px; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + position: relative; +} +.login-btn:disabled { opacity: 0.35; cursor: default; } +.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); } +.login-btn:not(:disabled):active { transform: translateY(0); } + +.login-btn-primary { + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12); +} +.login-btn-secondary { + background: linear-gradient(160deg, #3a2010 0%, #241408 100%); + color: #e4d4b4; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-btn-bot { + background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%); + color: #d0e0f0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} + +/* ── Connecting screen ──────────────────────────────────────────────── */ +.connecting { + font-family: var(--font-display); + font-size: 1.4rem; + font-style: italic; + margin-top: 4rem; + text-align: center; + color: var(--ui-parchment); + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Game-action buttons ─────────────────────────────────────────────── */ .btn { padding: 0.5rem 1.25rem; - font-size: 1rem; + font-size: 0.95rem; + font-family: var(--font-ui); + font-weight: 500; + letter-spacing: 0.03em; border: none; border-radius: 4px; cursor: pointer; - transition: opacity 0.15s; + transition: opacity 0.15s, box-shadow 0.15s; } .btn:disabled { opacity: 0.4; cursor: default; } -.btn-primary { background: #3a6b3a; color: #fff; } -.btn-secondary { background: #5a4a2a; color: #fff; } +.btn-primary { background: var(--ui-green-accent); color: #fff; } +.btn-secondary { background: var(--board-rail); color: #e8d8b8; } .btn-bot { background: #2a5a7a; color: #fff; } -.btn:not(:disabled):hover { opacity: 0.85; } +.btn:not(:disabled):hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} /* ── Game container ─────────────────────────────────────────────────── */ .game-container { display: flex; flex-direction: column; align-items: center; - gap: 0.75rem; + gap: 0.6rem; width: 100%; } -/* ── Language switcher ──────────────────────────────────────────────── */ -.lang-switcher { - display: flex; - gap: 0.25rem; -} +/* ── Language switcher (in-game) ────────────────────────────────────── */ +.lang-switcher { display: flex; gap: 0.25rem; } .lang-switcher button { - font-size: 0.75rem; + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.05em; padding: 0.15rem 0.4rem; - border: 1px solid rgba(0,0,0,0.3); + border: 1px solid rgba(200,164,72,0.3); border-radius: 3px; background: transparent; cursor: pointer; - color: inherit; - opacity: 0.6; + color: var(--ui-parchment); + opacity: 0.55; } - .lang-switcher button.lang-active { opacity: 1; - font-weight: bold; - background: rgba(0,0,0,0.12); -} - -.login-container .lang-switcher { - justify-content: flex-end; - margin-bottom: 1rem; + font-weight: 500; + background: rgba(200,164,72,0.15); + border-color: rgba(200,164,72,0.6); } /* ── Top bar ─────────────────────────────────────────────────────────── */ @@ -90,39 +285,67 @@ input[type="text"] { justify-content: space-between; align-items: center; width: 100%; + color: var(--ui-parchment); + font-size: 0.85rem; + opacity: 0.8; } .quit-link { - font-size: 0.85rem; - color: #5a4a2a; + font-size: 0.8rem; + color: var(--ui-parchment); text-decoration: underline; + text-underline-offset: 2px; cursor: pointer; + opacity: 0.7; +} +.quit-link:hover { opacity: 1; } + +/* ── Game status bar (§10b) — above board ───────────────────────────── */ +.game-status { + font-family: var(--font-display); + font-size: 1.2rem; + font-style: italic; + color: var(--ui-parchment); + text-align: center; + letter-spacing: 0.04em; + padding: 0.2rem 1rem 0; + width: 100%; + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */ +.game-sub-prompt { + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(240,228,192,0.5); + text-align: center; + letter-spacing: 0.04em; + padding: 0.15rem 1rem 0; + width: 100%; } /* ── Player score panel ─────────────────────────────────────────────── */ .player-score-panel { - background: #f5edd8; - border-radius: 6px; - padding: 0.5rem 1rem; - font-size: 0.9rem; - box-shadow: 0 1px 4px rgba(0,0,0,0.2); + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 1rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); width: 100%; + border-top: 2px solid var(--ui-gold-dark); } -.player-score-header { - margin-bottom: 0.3rem; -} +.player-score-header { margin-bottom: 0.35rem; } .player-name { - font-weight: bold; - font-size: 1rem; + font-family: var(--font-display); + font-weight: 600; + font-size: 1.05rem; + color: var(--ui-ink); + letter-spacing: 0.02em; } -.score-bars { - display: flex; - flex-direction: column; - gap: 4px; -} +.score-bars { display: flex; flex-direction: column; gap: 5px; } .score-bar-row { display: flex; @@ -131,51 +354,73 @@ input[type="text"] { } .score-bar-label { - font-size: 0.8rem; - color: #555; + font-size: 0.75rem; + color: #665544; width: 3.5rem; text-align: right; flex-shrink: 0; } +/* ── Points bar ─────────────────────────────────────────────────────── */ .score-bar { width: 140px; - height: 10px; - background: rgba(0,0,0,0.12); - border-radius: 5px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; overflow: hidden; flex-shrink: 0; } .score-bar-fill { height: 100%; - border-radius: 5px; - transition: width 0.3s; + border-radius: 4px; + transition: width 0.35s ease-out; } -.score-bar-points { background: #4a7a3a; } -.score-bar-holes { background: #7a4a2a; } +.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); } .score-bar-value { - font-size: 0.8rem; - color: #444; + font-size: 0.75rem; + color: #665544; min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */ +.peg-track { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.peg-hole { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.45); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.peg-hole.filled { + background: var(--ui-gold); + border-color: var(--ui-gold-dark); + box-shadow: 0 0 4px rgba(200,164,72,0.6); } .bredouille-badge { - font-size: 0.7rem; - font-weight: bold; - color: #fff; - background: #c07800; + font-size: 0.62rem; + font-weight: 500; + color: #fff8e0; + background: linear-gradient(135deg, #c88800, #8a5800); + border: 1px solid rgba(200,164,72,0.5); border-radius: 3px; - padding: 0.05em 0.35em; + padding: 0.1em 0.4em; + letter-spacing: 0.06em; cursor: default; -} - -.player-jans { - margin-top: 0.35rem; - border-top: 1px solid rgba(0,0,0,0.1); - padding-top: 0.25rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); } /* ── Board + side panel ─────────────────────────────────────────────── */ @@ -189,61 +434,83 @@ input[type="text"] { .side-panel { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.65rem; min-width: 160px; - padding-top: 0.25rem; + padding-top: 0.15rem; } -.action-buttons { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -/* ── Status bar ─────────────────────────────────────────────────────── */ -.status-bar { - display: flex; - gap: 1rem; - align-items: center; - font-size: 1.05rem; - font-weight: 500; -} +.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; } /* ── Dice bar ───────────────────────────────────────────────────────── */ .dice-bar { display: flex; align-items: center; - gap: 0.75rem; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: rgba(42,21,8,0.15); + border-radius: 6px; + border: 1px solid rgba(200,164,72,0.2); + width: fit-content; } /* ── Die face (SVG) ─────────────────────────────────────────────────── */ + +/* §5a — vigorous tumble: die bounces in from a random rotation */ +@keyframes die-tumble { + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } +} + +.die-face { + 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; +} + .die-face rect { - fill: #fffff0; + fill: #fffef0; stroke: #2a1a00; stroke-width: 2; + transition: fill 0.18s, stroke 0.18s; } .die-face circle { fill: #1a0a00; + transition: fill 0.18s; } -.die-face.die-used rect { - fill: #d8d4c8; - stroke: #8a7a60; + +/* Bar die slot — centered in the board bar */ +.bar-die-slot { + display: flex; + align-items: center; + justify-content: center; } -.die-face.die-used circle { - fill: #8a7a60; + +/* Double glow (§5c) */ +.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; } +.die-face.die-double { + filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3)); } +.die-face.die-used { animation: none; opacity: 0.55; } +.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } +.die-face.die-used circle { fill: #9a8a70; } + /* ── Jan panel ──────────────────────────────────────────────────────── */ .jan-panel { display: flex; flex-direction: column; gap: 2px; - background: #f5edd8; - border-radius: 6px; - padding: 0.4rem 1rem; - font-size: 0.9rem; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; box-shadow: 0 1px 4px rgba(0,0,0,0.15); min-width: 260px; + border-top: 2px solid rgba(138,106,40,0.35); } .jan-row { @@ -254,81 +521,127 @@ input[type="text"] { border-radius: 3px; } .jan-expandable { cursor: pointer; } -.jan-expandable:hover { background: rgba(0,0,0,0.06); } +.jan-expandable:hover { background: rgba(0,0,0,0.05); } .jan-positive { color: #1a5c1a; } .jan-negative { color: #8b1a1a; } .jan-label { flex: 1; } .jan-tag { - font-size: 0.75rem; + font-size: 0.72rem; padding: 0.1em 0.4em; border-radius: 3px; - background: rgba(0,0,0,0.08); - color: #555; + background: rgba(0,0,0,0.07); + color: #665544; white-space: nowrap; } -.jan-pts { font-weight: bold; text-align: right; min-width: 3rem; } +.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; } .jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } .jan-moves.hidden { display: none; } -.jan-move-line { font-family: monospace; font-size: 0.8rem; color: #444; } +.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; } -/* ── Game-over overlay ──────────────────────────────────────────────── */ +/* ── Game-over overlay (§12) ────────────────────────────────────────── */ .game-over-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + background: rgba(0,0,0,0.65); display: flex; align-items: center; justify-content: center; z-index: 100; } +@keyframes game-over-appear { + from { transform: translateY(-24px) scale(0.94); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + .game-over-box { - background: #f5edd8; + background: var(--ui-parchment); border-radius: 8px; - padding: 2rem 2.5rem; + padding: 2.5rem 3rem; text-align: center; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); display: flex; flex-direction: column; - gap: 1.25rem; - min-width: 260px; + gap: 1.1rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); } .game-over-box h2 { - font-size: 1.75rem; + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; } .game-over-winner { + font-family: var(--font-display); font-size: 1.25rem; - font-weight: bold; - color: #3a6b3a; + color: var(--ui-green-accent); + font-style: italic; } -.game-over-actions { +/* Final score ledger */ +.game-over-score { display: flex; - gap: 0.75rem; + align-items: center; justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.05); + border-radius: 5px; + border: 1px solid rgba(138,106,40,0.2); +} + +.game-over-score-name { + font-family: var(--font-display); + font-size: 0.9rem; + color: #665544; + font-style: italic; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-over-score-nums { + font-family: var(--font-display); + font-size: 2.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.08em; + line-height: 1; +} + +.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ +@keyframes score-panel-in { + from { transform: translateX(18px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } } -/* ── Scoring notification panel ────────────────────────────────────── */ .scoring-panel { - background: #f0ead0; - border-radius: 6px; - padding: 0.4rem 0.75rem; - font-size: 0.85rem; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.7rem; + font-size: 0.84rem; box-shadow: 0 1px 4px rgba(0,0,0,0.15); - border-left: 3px solid #4a7a3a; + border-left: 3px solid var(--ui-green-accent); display: flex; flex-direction: column; gap: 3px; + animation: score-panel-in 0.22s ease-out; } .scoring-total { - font-weight: bold; - font-size: 0.95rem; + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; color: #1a5c1a; } @@ -340,102 +653,212 @@ input[type="text"] { border-radius: 3px; cursor: default; } +.scoring-jan-row:hover { background: rgba(0,0,0,0.05); } -.scoring-jan-row:hover { background: rgba(0,0,0,0.06); } - -.scoring-panel-opp { - border-left-color: #7a5a3a; -} - -.scoring-panel-opp .scoring-total { color: #7a4a2a; } +.scoring-panel-opp { border-left-color: var(--board-rail); } +.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); } .scoring-hole { display: flex; align-items: center; gap: 0.4rem; - font-weight: bold; - color: #7a4a2a; - margin-top: 2px; - padding-top: 3px; + font-weight: 600; + color: var(--ui-red-accent); + margin-top: 3px; + padding-top: 4px; border-top: 1px solid rgba(0,0,0,0.1); } -.hold-go-buttons { +.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; } + +/* ── Board wrapper ──────────────────────────────────────────────────── */ +.board-wrapper { display: flex; - gap: 0.5rem; - margin-top: 4px; + flex-direction: column; + gap: 3px; } +/* ── Zone labels (§2a) — aligned with board quarters ───────────────── */ +/* Board border(4) + padding(4) = 8px inset each side */ +.zone-labels-row { + display: flex; + gap: 4px; + padding: 0 8px; +} + +.zone-label { + font-family: var(--font-display); + font-size: 0.57rem; + font-style: italic; + color: rgba(240,228,192,0.48); + letter-spacing: 0.1em; + text-align: center; + pointer-events: none; + line-height: 1; +} + +.zone-label-quarter { width: 370px; flex-shrink: 0; } +.zone-label-bar { width: 68px; flex-shrink: 0; } + /* ── Board ──────────────────────────────────────────────────────────── */ .board { - background: #2e6b2e; - border: 4px solid #1a3d1a; - border-radius: 8px; + background: var(--board-felt); + border: 4px solid var(--board-rail); + border-radius: 6px; padding: 4px; display: flex; flex-direction: column; gap: 4px; user-select: none; - box-shadow: 0 4px 12px rgba(0,0,0,0.4); + box-shadow: + 0 6px 16px rgba(0,0,0,0.5), + inset 0 1px 0 rgba(255,255,255,0.04); position: relative; } -.board-row { - display: flex; - gap: 4px; -} - -.board-quarter { - display: flex; - gap: 2px; -} +.board-row { display: flex; gap: 4px; } +.board-quarter { display: flex; gap: 2px; } .board-bar { - width: 20px; - background: #1a3d1a; - border-radius: 3px; + width: 68px; + background: var(--board-rail); + border-radius: 4px; + box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; } .board-center-bar { height: 12px; - background: #1a3d1a; - border-radius: 3px; + background: var(--board-rail); + border-radius: 2px; + box-shadow: inset 0 0 4px rgba(0,0,0,0.4); } -/* ── Fields ─────────────────────────────────────────────────────────── */ +/* ── Fields (§1) ────────────────────────────────────────────────────── */ +/* + * Each field is a transparent rectangle over the felt. + * The triangular flèche is drawn by ::before using clip-path. + * --fc controls the triangle colour; z-index:-1 keeps the triangle + * behind checkers; isolation:isolate confines the negative z-index. + */ .field { + --fc: var(--field-ivory); /* default triangle colour */ width: 60px; height: 180px; - background: #d4a843; - border-radius: 4px; + background: transparent; /* felt shows through between triangle tips */ + isolation: isolate; /* stacking context for z-index:-1 ::before */ + border-radius: 3px; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; padding: 4px 2px; position: relative; - transition: background 0.1s; } -/* Alternating field colours */ -.board-quarter .field:nth-child(odd) { background: #c49030; } -.board-quarter .field:nth-child(even) { background: #d4a843; } +/* Bot-row triangle: wide base at bottom, tip at top */ +.field::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; /* behind checkers & corner crown */ + background: var(--fc); + clip-path: polygon(0% 100%, 50% 0%, 100% 100%); + transition: background 0.12s; +} + +/* Top-row triangle: wide base at top, tip at bottom */ +.top-row .field::before { + clip-path: polygon(0% 0%, 100% 0%, 50% 100%); +} .top-row .field { justify-content: flex-start; } -.field.clickable { cursor: pointer; background: #aad060 !important; } -.field.clickable:hover { background: #88bb44 !important; } -.field.selected { background: #709a20 !important; outline: 2px solid #446622; } -/* .field.dest { background: #aad060 !important; } */ +/* ── Zone alternating colours (§2b) ────────────────────────────────── */ +/* petit-jan and grand-jan: burgundy / ivory */ +.board-quarter .field.zone-petit:nth-child(odd), +.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-petit:nth-child(even), +.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } -.field-num { - font-size: 0.65rem; - color: rgba(0,0,0,0.45); +/* Jan de retour — cooler: dark teal / silvery-green ivory */ +.board-quarter .field.zone-retour:nth-child(odd) { --fc: #1e3d32; } +.board-quarter .field.zone-retour:nth-child(even) { --fc: #e5eadc; } + +/* Dernier jan — warmer: amber-brown / warm amber ivory */ +.board-quarter .field.zone-dernier:nth-child(odd) { --fc: #6a2810; } +.board-quarter .field.zone-dernier:nth-child(even) { --fc: #f2dfa0; } + +/* ── Rest corner (§3) — before .clickable so green wins when interactive ── */ +.field.corner { --fc: var(--field-corner) !important; } + +/* Crown glyph sits behind checkers (z-index:-1) so it shows only on empty corners */ +.field.corner::after { + content: '♛'; position: absolute; - bottom: 2px; + z-index: -1; + bottom: 22px; + font-size: 0.7rem; + color: rgba(255,248,210,0.38); + pointer-events: none; + line-height: 1; +} +.top-row .field.corner::after { bottom: auto; top: 22px; } + +/* Corner pulse (§8d) — filter respects the triangle shape */ +@keyframes corner-pulse { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); } + 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); } +} +.field.corner.corner-available { + animation: corner-pulse 1.5s ease-in-out infinite; } -.top-row .field-num { bottom: auto; top: 2px; } +/* ── Exit-eligible highlight (§8c) — filter glow on triangle ───────── */ +@keyframes exit-glow { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); } + 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); } +} +.field.exit-eligible { + animation: exit-glow 2s ease-in-out infinite; +} + +/* ── Interactive states — after .corner to take visual priority ─────── */ +.field.clickable { + cursor: pointer; + --fc: #8fc840 !important; +} +.field.clickable:hover { --fc: #74aa28 !important; } +.field.selected { + --fc: #5a8a18 !important; + outline: 2px solid rgba(255,255,255,0.3); + outline-offset: -2px; +} + +/* ── Field numbers ──────────────────────────────────────────────────── */ +.field-num { + font-size: 0.58rem; + color: rgba(0,0,0,0.28); + position: absolute; + bottom: 3px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.board-quarter .field.zone-petit:nth-child(odd) .field-num, +.board-quarter .field.zone-grand:nth-child(odd) .field-num, +.board-quarter .field.zone-retour:nth-child(odd) .field-num, +.board-quarter .field.zone-dernier:nth-child(odd) .field-num { + color: rgba(240,215,190,0.38); +} + +.field.corner .field-num { color: rgba(255,248,200,0.4); } + +.top-row .field-num { bottom: auto; top: 3px; } /* ── Checkers ───────────────────────────────────────────────────────── */ .checker-stack { @@ -451,21 +874,121 @@ input[type="text"] { display: flex; align-items: center; justify-content: center; - font-size: 0.8rem; - font-weight: bold; - border: 2px solid rgba(0,0,0,0.3); - box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 1px 3px rgba(0,0,0,0.3); + font-size: 0.78rem; + 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; } -.checker + .checker { margin-top: 2px; } +.checker + .checker { margin-top: -4px; } .checker.white { - background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc); - color: #333; + background: radial-gradient(circle at 38% 32%, #ffffff, var(--checker-white) 65%, #d8cdb0); + color: #443322; } .checker.black { - background: radial-gradient(circle at 35% 35%, #555555, #111111); - color: #eee; + background: radial-gradient(circle at 38% 32%, #444444, #1c1008 65%, var(--checker-black)); + color: #c8b898; +} + +/* ── Hole toast (§6a) ───────────────────────────────────────────────── */ +@keyframes toast-rise { + from { transform: translate(-50%, -40%); opacity: 0; } + to { transform: translate(-50%, -50%); opacity: 1; } +} +@keyframes toast-fade { + from { opacity: 1; } + to { opacity: 0; pointer-events: none; } +} + +.hole-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(22,10,2,0.93); + border: 2px solid var(--ui-gold); + border-radius: 8px; + padding: 1.5rem 3.5rem; + text-align: center; + z-index: 50; + pointer-events: none; + box-shadow: + 0 12px 40px rgba(0,0,0,0.65), + 0 0 0 1px rgba(200,164,72,0.25), + inset 0 1px 0 rgba(200,164,72,0.1); + animation: + toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), + toast-fade 0.5s ease-in 1.4s forwards; +} + +.hole-toast-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.1em; + line-height: 1; +} + +.hole-toast-count { + font-family: var(--font-display); + font-size: 1.1rem; + color: rgba(200,164,72,0.68); + margin-top: 0.35rem; + letter-spacing: 0.06em; +} + +.hole-toast-bredouille { + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: rgba(200,164,72,0.55); + margin-top: 0.4rem; + text-transform: uppercase; +} + +/* ── Bredouille toast variant (§6d) — gold shimmer, larger entrance ─── */ +@keyframes bredouille-shimmer { + 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); } + 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); } +} +.hole-toast.hole-toast-bredouille { + border-width: 2.5px; + border-color: var(--ui-gold); + padding: 2rem 4rem; + animation: + toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1), + bredouille-shimmer 0.9s ease-in-out 0.3s 2, + toast-fade 0.5s ease-in 2.2s forwards; +} +.hole-toast.hole-toast-bredouille .hole-toast-title { + font-size: 3.75rem; +} +.hole-toast.hole-toast-bredouille .hole-toast-bredouille { + font-size: 0.85rem; + color: rgba(200,164,72,0.8); + letter-spacing: 0.14em; +} + +/* ── Checker lift on selected field (§4b) ───────────────────────────── */ +.field.selected .checker-stack { + transform: translateY(-5px); + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: transform 0.12s ease-out, filter 0.12s ease-out; +} + +/* ── Action buttons below board (§10c) ──────────────────────────────── */ +.board-actions { + display: flex; + gap: 0.55rem; + justify-content: center; + align-items: center; + flex-wrap: wrap; + min-height: 2rem; /* reserve height so layout doesn't shift when buttons appear */ } diff --git a/client_web/locales/en.json b/client_web/locales/en.json index 1fbad6d..94c2baa 100644 --- a/client_web/locales/en.json +++ b/client_web/locales/en.json @@ -48,5 +48,8 @@ "bredouille_applied": "Bredouille!", "hold": "Hold", "opp_scored_pts": "Opponent +{{ n }} pts", - "opp_hole_made": "Opponent hole! {{ holes }}/12" + "opp_hole_made": "Opponent hole! {{ holes }}/12", + "hint_move": "Click a highlighted field to move a checker", + "hint_hold_or_go": "Hold to keep points — Go to reset the setting", + "hint_continue": "Click Continue when ready" } diff --git a/client_web/locales/fr.json b/client_web/locales/fr.json index 09ab69b..397713d 100644 --- a/client_web/locales/fr.json +++ b/client_web/locales/fr.json @@ -48,5 +48,8 @@ "bredouille_applied": "Bredouille !", "hold": "Tenir", "opp_scored_pts": "Adversaire +{{ n }} pts", - "opp_hole_made": "Trou adverse ! {{ holes }}/12" + "opp_hole_made": "Trou adverse ! {{ holes }}/12", + "hint_move": "Cliquez un champ surligné pour déplacer", + "hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir", + "hint_continue": "Cliquez Continuer quand vous êtes prêt" } diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs index a60b99e..bd61102 100644 --- a/client_web/src/components/board.rs +++ b/client_web/src/components/board.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use trictrac_store::CheckerMove; 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. const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18]; @@ -15,6 +16,38 @@ const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12]; const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19]; const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13]; +/// The rest corner is field 12 (White) or field 13 (Black) in the store's coordinate system. +/// Returns true when `field_num` is the rest corner for this perspective. +#[allow(dead_code)] +fn is_rest_corner(field_num: u8, is_white: bool) -> bool { + if is_white { field_num == 12 } else { field_num == 13 } +} + +/// Zone CSS class for a field number (field coordinates are always White's 1-24). +fn field_zone_class(field_num: u8) -> &'static str { + match field_num { + 1..=6 => "zone-petit", + 7..=12 => "zone-grand", + 13..=18 => "zone-retour", + 19..=24 => "zone-dernier", + _ => "", + } +} + +/// Returns (d0_used, d1_used) for the bar dice display. +fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { + let mut d0 = false; + let mut d1 = false; + for &(from, to) in staged { + let dist = if from < to { to.saturating_sub(from) } else { from.saturating_sub(to) }; + 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) +} + /// Returns the displayed board value for `field_num` after applying `staged_moves`. /// Field numbers are always in white's coordinate system (1–24). fn displayed_value( @@ -59,8 +92,9 @@ fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) - } /// Pixel center of a board field in the SVG overlay coordinate space. -/// Geometry is derived from CSS: field 60px wide, 180px tall, board padding 4px, -/// board-row gap 4px, board-bar 20px, board-center-bar 12px. +/// Geometry: field 60×180px, board padding 4px, gap 4px, bar 20px, center-bar 12px. +/// With triangular flèches, arrows target the WIDE BASE of each triangle — +/// that is where the checker stack actually sits. fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> { if f == 0 || f > 24 { return None; @@ -69,24 +103,25 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> { match f { 13..=18 => (f - 13, false, true), 19..=24 => (f - 19, true, true), - 7..=12 => (12 - f, false, false), - 1..=6 => (6 - f, true, false), - _ => return None, + 7..=12 => (12 - f, false, false), + 1..=6 => (6 - f, true, false), + _ => return None, } } else { match f { - 1..=6 => (f - 1, false, true), - 7..=12 => (f - 7, true, true), + 1..=6 => (f - 1, false, true), + 7..=12 => (f - 7, true, true), 19..=24 => (24 - f, false, false), 13..=18 => (18 - f, true, false), - _ => return None, + _ => return None, } }; - // Left-quarter field i center x: 4 + i*62 + 30 = 34 + 62i - // Right-quarter field i center x: 4 + 370 + 4 + 20 + 4 + i*62 + 30 = 432 + 62i - let x = if right { 432.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 }; - // Top row center y: 4 + 90 = 94; bot row: 4 + 180 + 4 + 12 + 4 + 90 = 294 - let y = if top { 94.0 } else { 294.0 }; + // 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 + let x = if right { 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 base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360) + let y = if top { 30.0 } else { 358.0 }; Some((x, y)) } @@ -186,6 +221,12 @@ pub fn Board( staged_moves: RwSignal>, /// All valid two-move sequences for this turn (empty when not in move stage). valid_sequences: Vec<(CheckerMove, CheckerMove)>, + /// Dice to display in the center bars; None means dice not yet rolled (cups shown upright). + #[prop(default = None)] bar_dice: Option<(u8, u8)>, + /// Whether we're in the move stage (determines used/unused die appearance). + #[prop(default = false)] bar_is_move: bool, + /// Whether the dice are a double (golden glow). + #[prop(default = false)] bar_is_double: bool, ) -> impl IntoView { let board = view_state.board; let is_move_stage = view_state.active_mp_player == Some(player_id) @@ -196,6 +237,24 @@ pub fn Board( let is_white = player_id == 0; let hovered_moves = use_context::>>(); + // Exit-eligible (§8c): all the player's checkers are in their last jan. + // White last jan = fields 19-24 (board indices 18-23, positive values). + // Black last jan = fields 1-6 (board indices 0-5, negative values). + let board_snapshot = view_state.board; + let all_in_exit: bool; + let exit_field_test: fn(u8) -> bool; + if is_white { + let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum(); + let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum(); + all_in_exit = total > 0 && in_exit == total; + exit_field_test = |f| matches!(f, 19..=24); + } else { + let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum(); + let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum(); + all_in_exit = total > 0 && in_exit == total; + exit_field_test = |f| matches!(f, 1..=6); + } + // `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc). let fields_from = |nums: &[u8], is_top_row: bool| -> Vec { nums.iter() @@ -204,8 +263,14 @@ pub fn Board( // is Send, which Leptos requires for reactive attribute functions. let seqs_c = valid_sequences.clone(); let seqs_k = valid_sequences.clone(); + let corner_title = if is_rest_corner(field_num, is_white) { + Some("Coin de repos — must enter and leave with 2 checkers") + } else { + None + }; view! {
AnyView { + match bar_dice { + None => view! {
}.into_any(), + Some(dice_vals) => { + let die_val = if die_idx == 0 { dice_vals.0 } else { dice_vals.1 }; + view! { +
+ {move || { + let staged = staged_moves.get(); + let (u0, u1) = if bar_is_move { + bar_matched_dice_used(&staged, dice_vals) + } else { + (true, true) + }; + let used = if die_idx == 0 { u0 } else { u1 }; + view! { } + }} +
+ } + .into_any() + } + } + }; + let (tl, tr, bl, br) = if is_white { (&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W) } else { (&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B) }; + // Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective. + let (label_tl, label_tr, label_bl, label_br) = if is_white { + ("jan de retour", "dernier jan", "grand jan", "petit jan") + } else { + ("petit jan", "grand jan", "dernier jan", "jan de retour") + }; + view! { -
-
-
{fields_from(tl, true)}
-
-
{fields_from(tr, true)}
+ // board-wrapper keeps zone labels outside .board so the SVG overlay + // inside .board stays correctly positioned (position:absolute top:0 left:0 + // is relative to .board, not the wrapper). +
+
+
{label_tl}
+
+
{label_tr}
-
-
-
{fields_from(bl, false)}
-
-
{fields_from(br, false)}
+
+
+
{fields_from(tl, true)}
+
{bar_content(0)}
+
{fields_from(tr, true)}
+
+
+
+
{fields_from(bl, false)}
+
{bar_content(1)}
+
{fields_from(br, false)}
+
+ // SVG overlay: arrows for hovered jan moves + + {move || { + let Some(hm) = hovered_moves else { return vec![]; }; + let pairs = hm.get(); + if pairs.is_empty() { return vec![]; } + // Collect unique individual (from, to) moves; skip empty/exit. + let mut moves: Vec<(usize, usize)> = pairs.iter() + .flat_map(|(m1, m2)| [ + (m1.get_from(), m1.get_to()), + (m2.get_from(), m2.get_to()), + ]) + .filter(|&(f, t)| f != 0 && t != 0) + .collect(); + moves.sort_unstable(); + moves.dedup(); + moves.into_iter() + .filter_map(|(from, to)| { + let p1 = field_center(from, is_white)?; + let p2 = field_center(to, is_white)?; + Some(arrow_svg(p1, p2)) + }) + .collect() + }} + +
+
+
{label_bl}
+
+
{label_br}
- // SVG overlay: arrows for hovered jan moves - - {move || { - let Some(hm) = hovered_moves else { return vec![]; }; - let pairs = hm.get(); - if pairs.is_empty() { return vec![]; } - // Collect unique individual (from, to) moves; skip empty/exit. - let mut moves: Vec<(usize, usize)> = pairs.iter() - .flat_map(|(m1, m2)| [ - (m1.get_from(), m1.get_to()), - (m2.get_from(), m2.get_to()), - ]) - .filter(|&(f, t)| f != 0 && t != 0) - .collect(); - moves.sort_unstable(); - moves.dedup(); - moves.into_iter() - .filter_map(|(from, to)| { - let p1 = field_center(from, is_white)?; - let p2 = field_center(to, is_white)?; - Some(arrow_svg(p1, p2)) - }) - .collect() - }} -
} } diff --git a/client_web/src/components/die.rs b/client_web/src/components/die.rs index 1f83ea9..7b701e7 100644 --- a/client_web/src/components/die.rs +++ b/client_web/src/components/die.rs @@ -16,9 +16,21 @@ fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] { /// A single die face rendered as SVG. /// `value` 1–6 shows dots; 0 shows an empty face (not-yet-rolled). /// `used` dims the die. +/// `is_double` applies a golden glow (both dice same value). #[component] -pub fn Die(value: u8, used: bool) -> impl IntoView { - let cls = if used { "die-face die-used" } else { "die-face" }; +pub fn Die( + value: u8, + used: bool, + #[prop(default = false)] is_double: bool, +) -> impl IntoView { + let mut cls = if used { + "die-face die-used".to_string() + } else { + "die-face".to_string() + }; + if is_double && !used { + cls.push_str(" die-double"); + } let dots: Vec = dot_positions(value) .iter() .map(|&(cx, cy)| view! { }.into_any()) diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 642b79c..6657d94 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -9,35 +9,9 @@ use crate::i18n::*; use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage}; use super::board::Board; -use super::die::Die; use super::score_panel::PlayerScorePanel; use super::scoring::ScoringPanel; -#[allow(dead_code)] -/// Returns (d0_used, d1_used) by matching each staged move's distance to a die. -fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { - let mut d0 = false; - let mut d1 = false; - for &(from, to) in staged { - let dist = if from < to { - to.saturating_sub(from) - } else { - from.saturating_sub(to) - }; - 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) -} - - #[component] pub fn GameScreen(state: GameUiState) -> impl IntoView { let i18n = use_i18n(); @@ -139,18 +113,27 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Scoring notifications ────────────────────────────────────────────────── let my_scored_event = state.my_scored_event.clone(); let opp_scored_event = state.opp_scored_event.clone(); + let hole_toast_info = my_scored_event.as_ref() + .filter(|e| e.holes_gained > 0) + .map(|e| (e.holes_total, e.bredouille)); + + let is_double_dice = dice.0 == dice.1 && dice.0 != 0; // ── Capture for closures ─────────────────────────────────────────────────── let stage = vs.stage.clone(); let turn_stage = vs.turn_stage.clone(); let turn_stage_for_panel = turn_stage.clone(); + let turn_stage_for_sub = turn_stage.clone(); let room_id = state.room_id.clone(); let is_bot_game = state.is_bot_game; // ── Game-over info ───────────────────────────────────────────────────────── let stage_is_ended = stage == SerStage::Ended; let winner_is_me = my_score.holes >= 12; + let my_name_end = my_score.name.clone(); + let my_holes_end = my_score.holes; let opp_name_end = opp_score.name.clone(); + let opp_holes_end = opp_score.holes; view! {
@@ -180,6 +163,46 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Opponent score (above board) ───────────────────────────────── + // ── Status bar — full width, above board (§10b) ────────────────── +
+ {move || { + if let Some(ref reason) = pause_reason { + return String::from(match reason { + PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), + PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go), + PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move), + }); + } + let n = staged_moves.get().len(); + if is_move_stage { + t_string!(i18n, select_move, n = n + 1) + } else { + String::from(match (&stage, is_my_turn, &turn_stage) { + (SerStage::Ended, _, _) => t_string!(i18n, game_over), + (SerStage::PreGame, _, _) => t_string!(i18n, waiting_for_opponent), + (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), + (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), + (SerStage::InGame, true, _) => t_string!(i18n, your_turn), + (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), + }) + } + }} +
+ + // ── Contextual sub-prompt (§8a) ────────────────────────────────── + {move || { + let hint: String = if waiting_for_confirm { + t_string!(i18n, hint_continue).to_owned() + } else if is_move_stage { + t_string!(i18n, hint_move).to_owned() + } else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice { + t_string!(i18n, hint_hold_or_go).to_owned() + } else { + String::new() + }; + (!hint.is_empty()).then(|| view! {

{hint}

}) + }} + // ── Board + side panel ───────────────────────────────────────────
impl IntoView { selected_origin=selected_origin staged_moves=staged_moves valid_sequences=valid_sequences + bar_dice=show_dice.then_some(dice) + bar_is_move=is_move_stage + bar_is_double=is_double_dice /> - // ── Side panel ─────────────────────────────────────────────── + // ── Side panel (scoring panels only) ─────────────────────────
- // Status message -
- {move || { - if let Some(ref reason) = pause_reason { - return String::from(match reason { - PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), - PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go), - PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move), - }); - } - let n = staged_moves.get().len(); - if is_move_stage { - t_string!(i18n, select_move, n = n + 1) - } else { - String::from(match (&stage, is_my_turn, &turn_stage) { - (SerStage::Ended, _, _) => t_string!(i18n, game_over), - (SerStage::PreGame, _, _) => t_string!(i18n, waiting_for_opponent), - (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), - (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), - (SerStage::InGame, true, _) => t_string!(i18n, your_turn), - (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), - }) - } - }} -
- - // Dice (always shown when rolled, used state depends on whose turn) - {show_dice.then(|| view! { -
- {move || { - let (d0, d1) = if is_move_stage { - matched_dice_used(&staged_moves.get(), dice) - } else { - (true, true) - }; - view! { - - - } - }} -
- })} - - // Scoring notifications (own then opponent) {my_scored_event.map(|event| view! { })} {opp_scored_event.map(|event| view! { })} - - // Action buttons -
- {waiting_for_confirm.then(|| view! { - - })} - // Fallback Go button when no scoring panel (e.g. after reconnect) - {show_hold_go.then(|| view! { - - })} - {move || { - // Show the empty-move button only when (0,0) is a valid - // first or second move given what has already been staged. - let staged = staged_moves.get(); - let show = is_move_stage && staged.len() < 2 && ( - valid_seqs_empty.is_empty() || match staged.len() { - 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0), - 1 => { - let (f0, t0) = staged[0]; - valid_seqs_empty.iter() - .filter(|(m1, _)| { - m1.get_from() as u8 == f0 - && m1.get_to() as u8 == t0 - }) - .any(|(_, m2)| m2.get_from() == 0) - } - _ => false, - } - ); - show.then(|| view! { - - }) - }} -
+ // ── Action buttons below board (§10c) ──────────────────────────── +
+ {waiting_for_confirm.then(|| view! { + + })} + // Fallback Go button when no scoring panel (e.g. after reconnect) + {show_hold_go.then(|| view! { + + })} + {move || { + // Show the empty-move button only when (0,0) is a valid + // first or second move given what has already been staged. + let staged = staged_moves.get(); + let show = is_move_stage && staged.len() < 2 && ( + valid_seqs_empty.is_empty() || match staged.len() { + 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0), + 1 => { + let (f0, t0) = staged[0]; + valid_seqs_empty.iter() + .filter(|(m1, _)| { + m1.get_from() as u8 == f0 + && m1.get_to() as u8 == t0 + }) + .any(|(_, m2)| m2.get_from() == 0) + } + _ => false, + } + ); + show.then(|| view! { + + }) + }} +
+ // ── Player score (below board) ──────────────────────────────────── @@ -304,6 +286,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {

{t!(i18n, game_over)}

{winner_text}

+
+ {my_name_end} + + {format!("{my_holes_end} — {opp_holes_end}")} + + {opp_name_end.clone()} +
} })} + + // ── Hole toast (§6a) — board-centered overlay when a hole is won ── + {hole_toast_info.map(|(holes_total, bredouille)| view! { +
+
"Trou !"
+
{format!("{holes_total} / 12")}
+ {bredouille.then(|| view! { +
"× 2 bredouille"
+ })} +
+ })}
} } diff --git a/client_web/src/components/login_screen.rs b/client_web/src/components/login_screen.rs index 91e6d1b..689d0d7 100644 --- a/client_web/src/components/login_screen.rs +++ b/client_web/src/components/login_screen.rs @@ -17,61 +17,81 @@ pub fn LoginScreen(error: Option) -> impl IntoView { let cmd_tx_bot = cmd_tx; view! { - @@ -86,17 +86,22 @@ pub fn ScoringPanel( })}
})} - {show_hold_go.then(|| view! { -
- - -
+ {show_hold_go.then(|| { + let dismissed = RwSignal::new(false); + view! { +
+ + +
+ } })}
}