diff --git a/DESIGN.md b/DESIGN.md index 9add11c..3e24baf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -8,17 +8,17 @@ Polaris is a dark operations console: focused, quiet, and information-dense with ### Palette -| Role | Token | Usage | -|------|-------|-------| -| Surface / page | `bg-background` | Frontend page background | -| Surface / card | `bg-card` | Elevated content sections | -| Text / primary | `text-foreground` | Headings and primary labels | -| Text / secondary | `text-muted-foreground` | Descriptions and supporting copy | +| Role | Token | Usage | +| --------------------- | --------------------------------------- | ------------------------------------ | +| Surface / page | `bg-background` | Frontend page background | +| Surface / card | `bg-card` | Elevated content sections | +| Text / primary | `text-foreground` | Headings and primary labels | +| Text / secondary | `text-muted-foreground` | Descriptions and supporting copy | | Interactive / primary | `bg-primary`, `text-primary-foreground` | Primary actions and checked controls | -| Interactive / focus | `ring-ring` | Keyboard focus indicators | -| Border | `border-border` | Card and control boundaries | -| Status / error | `text-destructive` | Inline validation and failure copy | -| Status / success | `text-emerald-500` | Existing save confirmations | +| Interactive / focus | `ring-ring` | Keyboard focus indicators | +| Border | `border-border` | Card and control boundaries | +| Status / error | `text-destructive` | Inline validation and failure copy | +| Status / success | `text-emerald-500` | Existing save confirmations | ### Rules @@ -29,13 +29,13 @@ Polaris is a dark operations console: focused, quiet, and information-dense with ### Scale -| Level | Token | Usage | -|-------|-------|-------| -| Page title | `text-lg font-semibold` | Route heading | -| Card title | `font-semibold` | Section heading | -| Body | inherited `text-base` | Primary content | +| Level | Token | Usage | +| ---------- | ------------------------------- | ---------------------------- | +| Page title | `text-lg font-semibold` | Route heading | +| Card title | `font-semibold` | Section heading | +| Body | inherited `text-base` | Primary content | | Supporting | `text-sm text-muted-foreground` | Descriptions and helper text | -| Compact | `text-xs` | Secondary metadata only | +| Compact | `text-xs` | Secondary metadata only | ### Font Stack @@ -67,6 +67,16 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap - **Motion**: no section-level motion. - **Layout**: vertical stack. +### Poker table surface + +- **Purpose**: The poker table is a diegetic game surface. The velvet felt carries the board, the dealer station, opponent arc, and the player's action console without floating control overlays. +- **Material**: Preserve the existing velvet felt gradient, dark inset rim, and restrained gold trim. The action console is a darker inset panel with the same trim language, while chips use dark, clay, and gold-trim variants for action hierarchy. +- **Primitives**: `poker-felt-table`, `poker-dealer-station`, `poker-seat-arc-*`, `poker-action-console`, `poker-hand-placard`, and `poker-chip`. +- **States**: betting and waiting console states, acting-seat highlight, folded and eliminated players, showdown result, game-complete placement, open and occupied waiting seats, and reduced-motion deal presentation. +- **Accessibility**: The cosmetic dealer station is labelled as a station and is not included in player or seat data. Action controls remain native buttons and inputs with visible focus states. Card labels continue to describe face-up and face-down cards. +- **Motion**: Cards originate visually at the dealer shoe using transform and opacity with a 60ms stagger. Reduced motion removes the deal animation while retaining static card tilts and highlight states. +- **Layout**: The felt is a flat-bottom semicircle. Opponents occupy count-aware upper arc slots, the dealer is top-center, community cards sit in the middle, and the user's console is pinned to the flat bottom edge. Fullscreen increases felt depth without moving the console out of the viewport canvas. + ### Locker inventory surface - **Purpose**: The authenticated locker is a Polaris operations surface: a dense, readable inventory rather than a literal game replica. The grid is the focal region and selection context is kept adjacent to it. @@ -157,9 +167,9 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap ### Timing -| Type | Duration | Usage | -|------|----------|-------| -| Micro | 100–150ms | Switch and radio state feedback | +| Type | Duration | Usage | +| -------- | --------- | ---------------------------------- | +| Micro | 100–150ms | Switch and radio state feedback | | Standard | 200–300ms | Button hover and focus transitions | ### Rules @@ -182,6 +192,6 @@ The frontend uses a mixed strategy: card borders plus the existing shadcn card s ### Accepted Debt -| Item | Location | Why accepted | Owner / Exit | -|------|----------|--------------|--------------| +| Item | Location | Why accepted | Owner / Exit | +| ---------------------------------------------- | ------------- | -------------------------------------------------------------- | --------------------------------------------------------------- | | Existing success color uses `text-emerald-500` | Account forms | Preserve established feedback styling in this focused redesign | Consolidate account feedback tokens in a future account UI pass | diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 93ea881..d6e4c2f 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -1132,15 +1132,24 @@ } /* Poker surface: the felt is the game's one custom material. */ +.poker-table-stage { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + .poker-felt-table { --poker-felt-deep: #071c15; --poker-felt-mid: #0e3b29; --poker-felt-light: #1c5b3d; --poker-rim: #06130f; --poker-trim: #b08a44; + --poker-chip-clay: #9b6248; isolation: isolate; - border: 1px solid var(--poker-trim); - border-radius: 50% / 38%; + width: 100%; + border: 0.65rem solid var(--poker-rim); + border-radius: 50% / 48%; background: radial-gradient( circle at 50% 44%, @@ -1149,75 +1158,917 @@ ), radial-gradient(ellipse at 50% 50%, var(--poker-felt-mid) 0%, var(--poker-felt-deep) 78%); box-shadow: - inset 0 0 0 0.5rem var(--poker-rim), + 0 0 0 1px var(--poker-trim), + 0 0 0 0.2rem color-mix(in srgb, var(--poker-trim) 36%, transparent), + inset 0 0 0 0.12rem color-mix(in srgb, var(--poker-trim) 70%, transparent), inset 0 0 2.5rem color-mix(in srgb, var(--poker-felt-deep) 80%, black), 0 0.75rem 2rem rgb(0 0 0 / 35%); } .poker-felt-table::after { position: absolute; - inset: 0.85rem; + inset: 1.05rem; z-index: -1; border: 1px solid color-mix(in srgb, var(--poker-trim) 52%, transparent); - border-radius: 50% / 38%; + border-radius: 50% / 46%; content: ""; pointer-events: none; } -.poker-seat-north { - top: 2.5%; - left: 50%; - transform: translateX(-50%); -} - -.poker-seat-east { +.poker-seat-arc-left { top: 50%; - right: 2%; + left: 1.5%; transform: translateY(-50%); } -.poker-seat-south { - bottom: 2.5%; +.poker-seat-arc-center { + top: 2%; left: 50%; transform: translateX(-50%); } -.poker-seat-south-strip { - bottom: 1.5%; - left: 50%; - width: min(30rem, 88%); +.poker-seat-arc-right { + top: 50%; + right: 1.5%; + transform: translateY(-50%); +} + +.poker-seat-arc-far-left { + top: 34%; + left: 5%; + transform: translateY(-50%); +} + +.poker-seat-arc-center-left { + top: 6%; + left: 27%; transform: translateX(-50%); } +.poker-seat-arc-center-right { + top: 6%; + right: 27%; + transform: translateX(50%); +} + +.poker-seat-arc-far-right { + top: 34%; + right: 5%; + transform: translateY(-50%); +} + .poker-felt-fullscreen { - min-height: max(76vh, 36rem); + min-height: max(76vh, 42rem); + border-radius: 50% / 48%; } -.poker-seat-west { - top: 50%; - left: 2%; - transform: translateY(-50%); +.poker-felt-fullscreen::after { + border-radius: 50% / 46%; } .poker-seat-panel { - border-radius: 0.5rem; - background: color-mix(in srgb, var(--background) 88%, transparent); + position: relative; + border: 1px solid color-mix(in srgb, white 16%, transparent); + border-radius: 0.75rem; + background: rgb(0 0 0 / 38%); + box-shadow: 0 0.3rem 0.6rem rgb(0 0 0 / 22%); } .poker-waiting-seat { border-color: color-mix(in srgb, white 24%, transparent); - border-radius: 0.25rem; + border-radius: 0.65rem; background: rgb(0 0 0 / 28%); color: white; + text-align: center; } .poker-waiting-seat-occupied { border-color: color-mix(in srgb, var(--poker-trim) 62%, transparent); } +.poker-waiting-seat-south { + bottom: 1.5%; + left: 50%; + transform: translateX(-50%); +} + +.poker-waiting-name, +.poker-waiting-status { + max-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.poker-seat-avatar { + display: flex; + width: 1.9rem; + height: 1.9rem; + align-items: center; + justify-content: center; + border: 2px solid color-mix(in srgb, white 52%, transparent); + border-radius: 50%; + box-shadow: + inset 0 1px rgb(255 255 255 / 20%), + 0 0.2rem 0.45rem rgb(0 0 0 / 34%); + color: white; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + font-weight: 700; +} + +.poker-seat-avatar-tone-0 { + background: #2f6f5a; +} + +.poker-seat-avatar-tone-1 { + background: #7b543c; +} + +.poker-seat-avatar-tone-2 { + background: #465d87; +} + +.poker-seat-avatar-tone-3 { + background: #76506f; +} + +.poker-seat-avatar-open { + border-style: dashed; + background: color-mix(in srgb, var(--poker-rim) 78%, transparent); + color: rgb(255 255 255 / 58%); +} + +.poker-console-avatar { + width: 2.1rem; + height: 2.1rem; + flex: 0 0 auto; +} + +.poker-seat-nameplate { + min-width: 0; + padding-inline: 0.1rem; +} + +.poker-seat-stack { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: #f3d28b; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 1.1rem; + font-variant-numeric: tabular-nums; + font-weight: 700; + line-height: 1.15; +} + +.poker-seat-stack-chip { + width: 0.7rem; + height: 0.7rem; + flex: 0 0 auto; + border: 1px solid #d6b36a; + border-radius: 50%; + background: + radial-gradient( + circle at center, + color-mix(in srgb, var(--poker-chip-clay) 60%, white) 0 0.16rem, + transparent 0.17rem + ), + var(--poker-chip-clay); + box-shadow: inset 0 1px rgb(255 255 255 / 25%); +} + +.poker-seat-stack-flash-up { + animation: poker-stack-flash-up 900ms ease-out; +} + +.poker-seat-stack-flash-down { + animation: poker-stack-flash-down 900ms ease-out; +} + +@keyframes poker-stack-flash-up { + 0% { + color: #6ee7b7; + transform: scale(1.16); + } + 100% { + color: #f3d28b; + transform: scale(1); + } +} + +@keyframes poker-stack-flash-down { + 0% { + color: #fda4af; + transform: scale(0.92); + } + 100% { + color: #f3d28b; + transform: scale(1); + } +} + +.poker-seat-status-row { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + gap: 0.2rem; + margin-top: 0.15rem; +} + +.poker-seat-action-badge { + border: 1px solid color-mix(in srgb, var(--poker-trim) 60%, transparent); + border-radius: 9999px; + padding: 0.08rem 0.35rem; + background: color-mix(in srgb, var(--poker-trim) 14%, transparent); + color: #f3d28b; + font-size: 0.55rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; +} + +.poker-seat-action-winner { + border-color: color-mix(in srgb, #d6b36a 72%, transparent); + background: color-mix(in srgb, #d6b36a 20%, transparent); +} + +.poker-seat-cardbacks { + position: absolute; + top: calc(100% - 0.15rem); + left: 50%; + z-index: 3; + display: block; + width: 1.7rem; + height: 1rem; + transform: translateX(-50%); + pointer-events: none; +} + +.poker-seat-cardbacks span { + position: absolute; + top: 0.1rem; + display: block; + width: 0.78rem; + height: 1.12rem; + border: 1px solid color-mix(in srgb, white 30%, transparent); + border-radius: 0.14rem; + background-color: #101a33; + background-image: + linear-gradient(45deg, rgb(190 42 58 / 34%) 25%, transparent 25%), + linear-gradient(-45deg, rgb(190 42 58 / 34%) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgb(190 42 58 / 34%) 75%), + linear-gradient(-45deg, transparent 75%, rgb(190 42 58 / 34%) 75%); + background-position: + 0 0, + 0.2rem 0, + 0.2rem -0.2rem, + -0.2rem 0.2rem; + background-size: 0.4rem 0.4rem; + box-shadow: 0 0.15rem 0.3rem rgb(0 0 0 / 30%); +} + +.poker-seat-cardbacks span:first-child { + left: 0; + transform: rotate(-10deg); +} + +.poker-seat-cardbacks span:last-child { + right: 0; + transform: rotate(10deg); +} + +.poker-seat-reveal { + position: absolute; + top: calc(100% + 0.25rem); + left: 50%; + z-index: 5; + display: flex; + gap: 0.2rem; + transform: translateX(-50%); +} + +.poker-seat-reveal .poker-card:first-child { + --poker-card-resting-transform: rotate(-6deg); +} + +.poker-seat-reveal .poker-card:last-child { + --poker-card-resting-transform: rotate(6deg); +} + +.poker-seat-commit { + position: absolute; + z-index: 4; + display: flex; + align-items: center; + gap: 0.25rem; + border: 1px solid color-mix(in srgb, var(--poker-trim) 50%, transparent); + border-radius: 9999px; + padding: 0.18rem 0.45rem 0.18rem 0.25rem; + background: color-mix(in srgb, var(--poker-rim) 88%, transparent); + color: #f3d28b; + box-shadow: 0 0.2rem 0.4rem rgb(0 0 0 / 28%); + white-space: nowrap; +} + +.poker-seat-commit-chip { + position: relative; + display: inline-block; + width: 1.1rem; + height: 0.72rem; + border: 1px solid #d6b36a; + border-radius: 50%; + background: var(--poker-chip-clay); + box-shadow: 0 0.12rem 0 rgb(0 0 0 / 35%); + transform: rotate(-8deg); +} + +.poker-seat-commit-chip::before, +.poker-seat-commit-chip::after { + position: absolute; + left: -0.05rem; + width: 1.1rem; + height: 0.72rem; + border: 1px solid color-mix(in srgb, #d6b36a 85%, white); + border-radius: 50%; + content: ""; +} + +.poker-seat-commit-chip::before { + top: -0.2rem; + background: color-mix(in srgb, var(--poker-chip-clay) 82%, white); +} + +.poker-seat-commit-chip::after { + top: -0.4rem; + background: color-mix(in srgb, var(--poker-chip-clay) 72%, var(--poker-trim)); +} + +.poker-seat-arc-left .poker-seat-commit { + top: 50%; + right: -4.5rem; + transform: translateY(-50%); +} + +.poker-seat-arc-right .poker-seat-commit { + top: 50%; + left: -4.5rem; + transform: translateY(-50%); +} + +.poker-seat-arc-center .poker-seat-commit { + right: 50%; + bottom: -2.5rem; + transform: translateX(50%); +} + +.poker-seat-arc-far-left .poker-seat-commit, +.poker-seat-arc-center-left .poker-seat-commit { + right: -4.5rem; + bottom: -1.25rem; +} + +.poker-seat-arc-far-right .poker-seat-commit, +.poker-seat-arc-center-right .poker-seat-commit { + bottom: -1.25rem; + left: -4.5rem; +} + +.poker-dealer-button { + border-color: color-mix(in srgb, var(--poker-trim) 85%, white); + background: var(--poker-trim); + color: #20170b; + box-shadow: 0 0.15rem 0.3rem rgb(0 0 0 / 30%); +} + +.poker-deck-origin { + position: absolute; + top: -2.1rem; + left: 50%; + z-index: 2; + display: block; + width: 1.9rem; + height: 1.4rem; + opacity: 0.85; + transform: translateX(-50%); + pointer-events: none; +} + +.poker-deck-card { + position: absolute; + bottom: 0; + display: block; + width: 1.15rem; + height: 1.6rem; + border: 1px solid color-mix(in srgb, white 35%, transparent); + border-radius: 0.18rem; + background-color: #101a33; + background-image: + linear-gradient(45deg, rgb(190 42 58 / 34%) 25%, transparent 25%), + linear-gradient(-45deg, rgb(190 42 58 / 34%) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgb(190 42 58 / 34%) 75%), + linear-gradient(-45deg, transparent 75%, rgb(190 42 58 / 34%) 75%); + background-position: + 0 0, + 0.25rem 0, + 0.25rem -0.25rem, + -0.25rem 0.25rem; + background-size: 0.5rem 0.5rem; + box-shadow: 0 0.16rem 0.3rem rgb(0 0 0 / 28%); +} + +.poker-deck-card-back { + left: 0.2rem; + transform: translateY(-0.12rem) rotate(-9deg); +} + +.poker-deck-card-middle { + left: 0.38rem; + transform: translateY(-0.2rem) rotate(-3deg); +} + +.poker-deck-card-top { + left: 0.56rem; + transform: translateY(-0.26rem) rotate(4deg); +} + +.poker-pot-plaque { + display: flex; + min-width: 5.5rem; + flex-direction: column; + align-items: center; + gap: 0.1rem; + border: 1px solid color-mix(in srgb, var(--poker-trim) 74%, white); + border-radius: 0.45rem; + padding: 0.25rem 0.85rem 0.3rem; + background: color-mix(in srgb, var(--poker-rim) 92%, transparent); + box-shadow: + inset 0 1px rgb(255 255 255 / 10%), + 0 0.25rem 0.5rem rgb(0 0 0 / 24%); +} + +.poker-pot-plaque .poker-console-kicker { + color: #f3d28b; +} + +.poker-table-turn-clock { + width: min(12rem, 80%); + color: rgb(255 255 255 / 72%); +} + +.poker-action-console { + position: relative; + display: grid; + grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 0.8fr) minmax(13rem, 1.6fr); + align-items: stretch; + gap: 0.75rem; + width: min(40rem, 96%); + min-height: 9rem; + margin-inline: auto; + padding: 0.7rem; + border: 1px solid var(--poker-trim); + border-radius: 0.35rem; + background: + linear-gradient(135deg, rgb(0 0 0 / 34%), transparent 48%), + color-mix(in srgb, var(--poker-rim) 92%, var(--poker-felt-deep)); + box-shadow: + inset 0 1px rgb(255 255 255 / 10%), + inset 0 0.2rem 1rem rgb(0 0 0 / 24%), + 0 0.35rem 1.4rem rgb(0 0 0 / 24%); + color: white; +} + +.poker-action-console-waiting { + opacity: 0.86; +} + +.poker-sound-toggle { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.poker-sound-volume { + width: 4.5rem; + accent-color: var(--poker-trim); +} + +.poker-action-console:not(.poker-action-console-complete) { + grid-template-columns: minmax(0, 1.5fr) minmax(10rem, 0.8fr); +} +.poker-viewer-cards { + position: absolute; + bottom: 7rem; + left: 50%; + z-index: 14; + display: flex; + transform: translateX(-50%); + pointer-events: none; +} + +.poker-viewer-cards .poker-card-viewer-right { + margin-left: -0.45rem; +} + +.poker-viewer-cards .poker-card-folded { + opacity: 0.5; +} + +.poker-action-toolbar { + position: absolute; + bottom: 1.15rem; + left: 50%; + z-index: 15; + display: flex; + width: min(30rem, calc(100% - 2rem)); + justify-content: center; + padding: 0.45rem; + border: 1px solid color-mix(in srgb, var(--poker-trim) 64%, transparent); + border-radius: 0.5rem; + background: color-mix(in srgb, var(--poker-rim) 88%, transparent); + box-shadow: + inset 0 1px rgb(255 255 255 / 10%), + 0 0.35rem 0.9rem rgb(0 0 0 / 30%); + transform: translateX(-50%); +} + +.poker-action-toolbar-waiting { + width: fit-content; + max-width: calc(100% - 2rem); + padding-inline: 0.75rem; + color: rgb(255 255 255 / 64%); + font-size: 0.7rem; +} + +.poker-action-toolbar .poker-betting-controls-compact { + width: 100%; + gap: 0.4rem; +} + +.poker-betting-controls-compact .poker-compact-actions { + justify-content: center; + gap: 0.4rem; +} + +.poker-betting-controls-compact .poker-chip { + min-height: 2rem; + padding-inline: 0.7rem; +} + +.poker-betting-controls-compact .poker-betting-message { + justify-content: center; + min-height: 1rem; +} + +.poker-wager-panel-compact { + position: absolute; + bottom: calc(100% + 0.5rem); + left: 50%; + z-index: 16; + width: min(26rem, calc(100% - 1rem)); + max-width: calc(100% - 1rem); + max-height: min(18rem, calc(100dvh - 8rem)); + margin: 0; + overflow-y: auto; + transform: translateX(-50%); +} + +.poker-wager-panel-compact .poker-compact-wager-line { + flex-wrap: wrap; + gap: 0.45rem; +} + +.poker-wager-panel-compact .poker-wager-range { + flex: 1 1 100%; +} + +.poker-wager-panel-compact .poker-wager-input { + width: 7rem; + flex: 0 1 7rem; +} + +.poker-wager-panel-compact .poker-compact-quick-row { + align-items: center; + gap: 0.3rem; +} + +.poker-wager-panel-compact .poker-quick-chip { + min-height: 1.65rem; + padding-inline: 0.55rem; +} + +.poker-console-player, +.poker-console-economy, +.poker-console-actions { + min-width: 0; +} + +.poker-console-player { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 0.6rem; +} + +.poker-console-identity { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 0.45rem; +} + +.poker-console-identity > .flex { + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; + min-width: 0; +} + +.poker-console-identity > .flex > .min-w-0 { + width: 100%; +} + +.poker-console-role-row { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + gap: 0.2rem; + max-width: 100%; +} + +.poker-console-role-row > * { + border-color: color-mix(in srgb, var(--poker-trim) 70%, transparent); + background: color-mix(in srgb, var(--poker-trim) 18%, transparent); + color: #f3d28b; + font-size: 0.58rem; +} + +.poker-hand-placard { + display: block; + width: fit-content; + max-width: 100%; + border: 1px solid color-mix(in srgb, var(--poker-trim) 78%, white); + border-radius: 0.15rem; + padding: 0.28rem 0.45rem; + background: linear-gradient(180deg, rgb(176 138 68 / 32%), rgb(176 138 68 / 12%)); + color: #f3d28b; + font-size: 0.68rem; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.poker-console-economy { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.7rem; + border-inline: 1px solid color-mix(in srgb, var(--poker-trim) 26%, transparent); + padding-inline: 0.7rem; +} + +.poker-chip-readout { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.poker-chip-stack { + position: relative; + display: block; + width: 1.7rem; + height: 1.1rem; + flex: 0 0 auto; + border: 2px solid #d6b36a; + border-radius: 50%; + background: var(--poker-chip-clay); + box-shadow: 0 0.16rem 0 rgb(0 0 0 / 35%); + transform: rotate(-6deg); +} + +.poker-chip-stack::before, +.poker-chip-stack::after { + position: absolute; + left: -0.12rem; + width: 1.7rem; + height: 1.1rem; + border: 2px solid color-mix(in srgb, #d6b36a 85%, white); + border-radius: 50%; + content: ""; +} + +.poker-chip-stack::before { + top: -0.32rem; + background: color-mix(in srgb, var(--poker-chip-clay) 82%, white); +} + +.poker-chip-stack::after { + top: -0.64rem; + background: color-mix(in srgb, var(--poker-chip-clay) 72%, var(--poker-trim)); +} + +.poker-console-readout-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.15rem; +} + +.poker-console-kicker { + color: rgb(255 255 255 / 54%); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.58rem; + letter-spacing: 0.12em; + line-height: 1.1; + text-transform: uppercase; +} + +.poker-console-timer { + min-width: 0; +} + +.poker-console-timer [role="timer"] { + color: rgb(255 255 255 / 72%); +} + +.poker-console-timer [aria-label] > div:last-child { + background: rgb(255 255 255 / 12%); +} + +.poker-console-timer [aria-label] > div:last-child > div { + background: var(--poker-trim); +} + +.poker-console-actions { + display: flex; + flex-direction: column; + justify-content: center; +} + +.poker-betting-controls { + min-width: 0; +} + +.poker-chip { + min-height: 2.2rem; + border-radius: 9999px; + padding-inline: 0.8rem; + border-width: 1px; + box-shadow: + inset 0 1px rgb(255 255 255 / 16%), + 0 0.16rem 0 rgb(0 0 0 / 28%); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + transition: + transform 150ms ease, + filter 150ms ease, + background-color 150ms ease; +} + +.poker-chip:hover:not(:disabled) { + filter: brightness(1.1); + transform: translateY(-1px); +} + +.poker-chip:active:not(:disabled) { + transform: translateY(1px) scale(0.98); +} + +.poker-chip-fold { + border-color: color-mix(in srgb, white 26%, transparent); + background: var(--poker-rim); + color: rgb(255 255 255 / 78%); +} + +.poker-chip-call { + border-color: color-mix(in srgb, var(--poker-chip-clay) 90%, white); + background: var(--poker-chip-clay); + color: #fff4ed; +} + +.poker-chip-wager { + border-color: #e0bd6f; + background: linear-gradient(180deg, #d6b36a, var(--poker-trim)); + color: #20170b; +} + +/* The complete-console primary action must stay readable even if the + gradient's var chain or the shadcn background utilities win the cascade. */ +.poker-complete-actions .poker-chip-wager { + color: #20170b; + background-color: #d6b36a; + background-image: linear-gradient(180deg, #d6b36a, color-mix(in srgb, #d6b36a 72%, #20170b)); +} + +.poker-wager-panel { + border-color: color-mix(in srgb, var(--poker-trim) 48%, transparent); + background: color-mix(in srgb, var(--poker-felt-deep) 86%, black); + box-shadow: inset 0 1px rgb(255 255 255 / 8%); +} + +.poker-wager-range { + accent-color: var(--poker-trim); +} + +.poker-wager-input { + border-color: color-mix(in srgb, var(--poker-trim) 45%, transparent); + background: rgb(0 0 0 / 24%); +} + +.poker-quick-chip { + min-height: 1.8rem; + border-color: color-mix(in srgb, var(--poker-trim) 45%, transparent); + border-radius: 9999px; + background: rgb(255 255 255 / 5%); + color: rgb(255 255 255 / 74%); + font-size: 0.65rem; +} + +.poker-betting-message { + color: rgb(255 255 255 / 54%); +} + +.poker-complete-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; +} + +.poker-complete-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.2rem; +} + .poker-card { + --poker-card-resting-transform: none; + border-radius: 0.3rem; perspective: 45rem; vertical-align: middle; + transform: var(--poker-card-resting-transform); + transform-origin: center bottom; +} + +.poker-card-motion { + display: block; + width: 100%; + height: 100%; + transform-origin: center bottom; + transform-style: preserve-3d; +} + +.poker-card-viewer-left { + --poker-card-resting-transform: perspective(40rem) rotate(-7deg) rotateX(5deg) translateY(0.12rem); +} + +.poker-card-viewer-right { + --poker-card-resting-transform: perspective(40rem) rotate(7deg) rotateX(5deg) translateY(0.12rem); +} + +.poker-seat-arc-left .poker-card:nth-child(1) { + --poker-card-resting-transform: rotate(7deg) rotateX(-4deg); +} + +.poker-seat-arc-left .poker-card:nth-child(2) { + --poker-card-resting-transform: rotate(11deg) rotateX(-4deg); +} + +.poker-seat-arc-center .poker-card { + --poker-card-resting-transform: rotate(1deg) rotateX(-3deg); +} + +.poker-seat-arc-right .poker-card:nth-child(1) { + --poker-card-resting-transform: rotate(-7deg) rotateX(-4deg); +} + +.poker-seat-arc-right .poker-card:nth-child(2) { + --poker-card-resting-transform: rotate(-11deg) rotateX(-4deg); +} + +.poker-seat-arc-far-left .poker-card, +.poker-seat-arc-center-left .poker-card { + --poker-card-resting-transform: rotate(8deg) rotateX(-4deg); +} + +.poker-seat-arc-far-right .poker-card, +.poker-seat-arc-center-right .poker-card { + --poker-card-resting-transform: rotate(-8deg) rotateX(-4deg); +} + +.poker-card-community { + --poker-card-resting-transform: rotate(-1.5deg) rotateX(2deg) translateY(0.08rem); +} + +.poker-card-community:nth-child(2n) { + --poker-card-resting-transform: rotate(1.5deg) rotateX(2deg) translateY(-0.04rem); +} + +.poker-card-community:nth-child(3n) { + --poker-card-resting-transform: rotate(-2deg) rotateX(2deg) translateY(0.12rem); } .poker-card-inner { @@ -1258,6 +2109,28 @@ line-height: 1; } +.poker-card-face--light { + background: #f8f6ef; + color: #1d2733; + border-color: rgb(0 0 0 / 30%); +} + +.poker-card-front--svg { + padding: 0; + background: transparent; + border-color: transparent; + overflow: hidden; +} + +.poker-card-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: fill; + pointer-events: none; +} + .poker-card-back { transform: rotateY(180deg); background-color: #101a33; @@ -1275,39 +2148,273 @@ } .poker-card-deal { + --poker-deal-x: 0rem; + --poker-deal-y: -5rem; animation: poker-card-deal-in 260ms ease both; } +.poker-seat-arc-left .poker-card-deal, +.poker-seat-arc-far-left .poker-card-deal, +.poker-seat-arc-center-left .poker-card-deal { + --poker-deal-x: clamp(5rem, 18vw, 16rem); + --poker-deal-y: 0rem; +} + +.poker-seat-arc-right .poker-card-deal, +.poker-seat-arc-far-right .poker-card-deal, +.poker-seat-arc-center-right .poker-card-deal { + --poker-deal-x: clamp(-16rem, -18vw, -5rem); + --poker-deal-y: 0rem; +} + +.poker-seat-arc-center .poker-card-deal { + --poker-deal-y: clamp(4rem, 8vw, 9rem); +} + +.poker-card-community .poker-card-deal { + --poker-deal-y: -2rem; +} + +.poker-card-viewer-left.poker-card-deal { + --poker-deal-x: clamp(1rem, 4vw, 4rem); + --poker-deal-y: clamp(-20rem, -25vw, -8rem); +} + +.poker-card-viewer-right.poker-card-deal { + --poker-deal-x: clamp(-4rem, -4vw, -1rem); + --poker-deal-y: clamp(-20rem, -25vw, -8rem); +} + .poker-countdown-fill { min-width: 0; } +.poker-popup-stack { + position: absolute; + top: -0.9rem; + left: 50%; + z-index: 30; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + transform: translateX(-50%); + pointer-events: none; +} + +.poker-popup-viewer-spot { + top: auto; + bottom: 13%; +} + +.poker-popup-stack-below { + top: calc(100% + 0.5rem); +} + +.poker-popup-stack-below .poker-popup { + animation-name: poker-popup-float-down; +} + +.poker-popup-announcements { + top: 24%; + z-index: 25; +} + +.poker-popup-announcements .poker-popup { + animation-name: poker-popup-float-down; +} + +.poker-popup-leave { + color: rgb(255 255 255 / 0.85); + border-color: rgb(255 255 255 / 0.35); + background: rgb(23 23 23 / 0.92); + animation: poker-popup-float-down 3s ease-out forwards; +} + +.poker-popup { + animation: poker-popup-float 1.5s ease-out forwards; + border: 1px solid; + border-radius: 0.375rem; + padding: 0.1rem 0.5rem; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.poker-popup-action { + color: #bfdbfe; + border-color: rgb(147 197 253 / 0.5); + background: rgb(30 58 138 / 0.85); +} + +.poker-popup-wager { + color: #fde68a; + border-color: rgb(252 211 77 / 0.55); + background: rgb(120 53 15 / 0.88); +} + +.poker-popup-win { + color: #a7f3d0; + border-color: rgb(110 231 183 / 0.55); + background: rgb(6 78 59 / 0.9); +} + +.poker-popup-fold { + color: rgb(255 255 255 / 0.75); + border-color: rgb(255 255 255 / 0.25); + background: rgb(0 0 0 / 0.7); +} + +@keyframes poker-popup-float { + 0% { + opacity: 0; + transform: translateY(0.6rem) scale(0.7); + } + 15% { + opacity: 1; + transform: translateY(0) scale(1.1); + } + 30% { + transform: translateY(-0.3rem) scale(1); + } + 100% { + opacity: 0; + transform: translateY(-2.75rem) scale(1); + } +} + +@keyframes poker-popup-float-down { + 0% { + opacity: 0; + transform: translateY(-0.6rem) scale(0.7); + } + 15% { + opacity: 1; + transform: translateY(0) scale(1.1); + } + 30% { + transform: translateY(0.3rem) scale(1); + } + 100% { + opacity: 0; + transform: translateY(2.75rem) scale(1); + } +} + +@keyframes poker-popup-fade { + 0% { + opacity: 0; + } + 20% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + @keyframes poker-card-deal-in { from { opacity: 0; - transform: translateY(-0.5rem) scale(0.92); + transform: translate3d(var(--poker-deal-x), var(--poker-deal-y), 0) scale(0.84); } to { opacity: 1; - transform: translateY(0) scale(1); + transform: var(--poker-card-resting-transform); } } @media (max-width: 640px) { .poker-felt-table::after { - inset: 0.65rem; + inset: 0.8rem 0.7rem; } - .poker-seat-east { - right: 1%; + .poker-seat-arc-left { + left: 4.5%; } - .poker-seat-west { - left: 1%; + .poker-seat-arc-right { + right: 4.5%; + } + + .poker-seat-arc-far-left { + left: 4%; + } + + .poker-seat-arc-far-right { + right: 4%; + } +} + +@media (max-width: 760px) { + .poker-action-console { + grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); + } + + .poker-console-actions { + grid-column: 1 / -1; + border-top: 1px solid color-mix(in srgb, var(--poker-trim) 26%, transparent); + padding-top: 0.65rem; + } + + .poker-complete-actions { + align-items: flex-start; + } +} + +@media (max-width: 480px) { + .poker-action-console { + grid-template-columns: 1fr; + gap: 0.55rem; + } + + .poker-action-console:not(.poker-action-console-complete) { + grid-template-columns: 1fr; + } + + .poker-console-economy { + flex-direction: row; + align-items: center; + justify-content: space-between; + border-inline: 0; + border-block: 1px solid color-mix(in srgb, var(--poker-trim) 26%, transparent); + padding-block: 0.55rem; + padding-inline: 0; + } + + .poker-console-actions { + grid-column: auto; + border-top: 0; + padding-top: 0; + } + + .poker-complete-actions { + flex-direction: column; } } @media (prefers-reduced-motion: reduce) { + .poker-chip { + transition: none; + } + + .poker-popup { + animation: poker-popup-fade 1.5s ease-out forwards; + } + + .poker-popup-stack-below .poker-popup, + .poker-popup-announcements .poker-popup { + animation-name: poker-popup-fade; + animation-duration: 3s; + } + + .poker-seat-stack-flash-up, + .poker-seat-stack-flash-down { + animation: none; + } + .poker-card-inner, .poker-card-deal { animation: none; diff --git a/src/components/frontend/minigames/poker/BettingControls.tsx b/src/components/frontend/minigames/poker/BettingControls.tsx index e9558dd..2788203 100644 --- a/src/components/frontend/minigames/poker/BettingControls.tsx +++ b/src/components/frontend/minigames/poker/BettingControls.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; import type { PokerActionKind, PokerLegalActions } from "@/lib/minigames/poker"; import { pokerAction, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; import { Button } from "@/components/ui/button"; @@ -14,6 +15,8 @@ interface BettingControlsProps { readonly potTotal: number; readonly yourTurn: boolean; readonly onSnapshot: (snapshot: PokerSnapshot) => void; + readonly onActionFeedback?: () => void; + readonly compact?: boolean; } function clampAmount(value: number, min: number, max: number): number { @@ -32,10 +35,13 @@ export function BettingControls({ potTotal, yourTurn, onSnapshot, + onActionFeedback, + compact = false, }: BettingControlsProps) { const [pendingAction, setPendingAction] = useState(null); const [error, setError] = useState(null); const [amount, setAmount] = useState(legalActions.minBetTo); + const [wagerOpen, setWagerOpen] = useState(false); const wagerKind: PokerActionKind | null = legalActions.canBet ? "bet" @@ -53,8 +59,13 @@ export function BettingControls({ useEffect(() => { setPendingAction(null); setError(null); + setWagerOpen(false); }, [snapshotVersion]); + useEffect(() => { + if (compact && (!yourTurn || wagerKind === null)) setWagerOpen(false); + }, [compact, wagerKind, yourTurn]); + const submit = async (kind: PokerActionKind, wager?: number) => { setError(null); setPendingAction(kind); @@ -65,6 +76,7 @@ export function BettingControls({ setPendingAction(null); return; } + if (compact) setWagerOpen(false); if (result.data) onSnapshot(result.data); } catch (caught) { setError(caught instanceof Error ? caught.message : "Could not send the table action."); @@ -84,44 +96,97 @@ export function BettingControls({ setAmount(clampAmount(nextAmount, wagerMin, wagerMax)); }; + const submitFromControl = (kind: PokerActionKind, wager?: number) => { + onActionFeedback?.(); + void submit(kind, wager); + }; + + const compactAction = legalActions.canCall + ? { kind: "call" as const, label: `Call ${amountLabel(legalActions.callAmount)}` } + : legalActions.canCheck + ? { kind: "check" as const, label: "Check" } + : null; + return ( -
-
+
+
- - + {compact ? ( + compactAction && ( + + ) + ) : ( + <> + + + + )} + {compact && wagerKind && wagerMax >= wagerMin && ( + + )}
- {wagerKind && wagerMax >= wagerMin && ( - - + {wagerKind && wagerMax >= wagerMin && (!compact || wagerOpen) && ( + + {wagerKind === "bet" ? "Bet size" : "Raise to"} - -
+ +
-
+
{(["min", "half", "pot", "all"] as const).map((choice) => ( @@ -181,11 +250,12 @@ export function BettingControls({ )} -
- {!yourTurn && Waiting for the table to act.} - {pendingAction && ( - Waiting for the table update... - )} +
+ {!compact && !yourTurn && Waiting for the table to act.} + {pendingAction && Waiting for the table update...} {error && {error}}
diff --git a/src/components/frontend/minigames/poker/PokerActionConsole.tsx b/src/components/frontend/minigames/poker/PokerActionConsole.tsx new file mode 100644 index 0000000..3208f56 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerActionConsole.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { PublicPlayerView } from "@/lib/minigames/poker"; +import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { PokerClock } from "./PokerClock"; + +type SeatedSnapshot = PokerSnapshot & { + readonly you: NonNullable; +}; + +interface PokerActionConsoleProps { + readonly snapshot: SeatedSnapshot; + readonly handLabel: string | null; + readonly hostSeatId: number | null; + readonly starting: boolean; + readonly leaving: boolean; + readonly onStart: () => void; + readonly onLeave: () => void; +} + +function TableActionButtons({ + view, + hostSeatId, + starting, + leaving, + onStart, + onLeave, +}: { + readonly view: SeatedSnapshot["you"]; + readonly hostSeatId: number | null; + readonly starting: boolean; + readonly leaving: boolean; + readonly onStart: () => void; + readonly onLeave: () => void; +}) { + return ( +
+
+ Table complete + The final placements are in. +
+
+ {view.viewerSeat === hostSeatId && ( + + )} + +
+
+ ); +} + +function ViewerRoleBadges({ view }: { readonly view: SeatedSnapshot["you"] }) { + return ( +
+ {view.buttonSeat === view.viewerSeat && Dealer button} + {view.smallBlindSeat === view.viewerSeat && Small blind} + {view.bigBlindSeat === view.viewerSeat && Big blind} +
+ ); +} + +export function PokerActionConsole({ + snapshot, + handLabel, + hostSeatId, + starting, + leaving, + onStart, + onLeave, +}: PokerActionConsoleProps) { + const view = snapshot.you; + const viewerPlayer: PublicPlayerView | undefined = view.players.find( + (player) => player.seatId === view.viewerSeat, + ); + const chipsInPlay = viewerPlayer?.committed ?? 0; + const viewerInitial = (viewerPlayer?.name ?? "You").trim().charAt(0).toUpperCase() || "?"; + + return ( +
+
+ +
+
+
+

+ {viewerPlayer?.name ?? "You"} + (you) +

+

+ Stack {viewerPlayer?.stack.toLocaleString() ?? "0"} +

+
+ +
+ {handLabel && You have: {handLabel}} +
+
+ +
+
+
+ {view.phase === "betting" && ( +
+ +
+ )} +
+ + {view.phase === "game-complete" && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerActionToolbar.tsx b/src/components/frontend/minigames/poker/PokerActionToolbar.tsx new file mode 100644 index 0000000..03cc3da --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerActionToolbar.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; +import { BettingControls } from "./BettingControls"; + +type SeatedSnapshot = PokerSnapshot & { + readonly you: NonNullable; +}; + +interface PokerActionToolbarProps { + readonly snapshot: SeatedSnapshot; + readonly onSnapshot: (snapshot: PokerSnapshot) => void; + readonly onActionFeedback: () => void; +} + +export function PokerActionToolbar({ + snapshot, + onSnapshot, + onActionFeedback, +}: PokerActionToolbarProps) { + const view = snapshot.you; + + if (view.phase !== "betting") return null; + + if (!view.yourTurn) { + const actingPlayer = view.players.find((player) => player.seatId === view.toActSeat); + return ( +
+ {actingPlayer ? `Waiting for ${actingPlayer.name}` : "Waiting for the table"} +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerDeckOrigin.tsx b/src/components/frontend/minigames/poker/PokerDeckOrigin.tsx new file mode 100644 index 0000000..669fb5e --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerDeckOrigin.tsx @@ -0,0 +1,11 @@ +"use client"; + +export function PokerDeckOrigin() { + return ( + + ); +} diff --git a/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx b/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx new file mode 100644 index 0000000..6732e74 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { Card, PublicPlayerView } from "@/lib/minigames/poker"; +import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; +import { Badge } from "@/components/ui/badge"; +import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CardView } from "./CardView"; +import { PokerActionConsole } from "./PokerActionConsole"; +import { PokerActionToolbar } from "./PokerActionToolbar"; +import { PokerClock } from "./PokerClock"; +import { PokerDeckOrigin } from "./PokerDeckOrigin"; +import { PokerPopupBubble } from "./PokerPopups"; +import { PokerSeat } from "./PokerSeat"; +import type { ActivePokerPopup } from "./usePokerPopups"; +import { isTopSeatPosition, seatPosition } from "./seatPosition"; + +export type SeatedSnapshot = PokerSnapshot & { + readonly you: NonNullable; +}; + +export interface FeltCanvasActionProps { + readonly starting: boolean; + readonly leaving: boolean; + readonly onStart: () => void; + readonly onLeave: () => void; +} + +interface PokerFeltCanvasProps { + readonly snapshot: SeatedSnapshot; + readonly onSnapshot: (snapshot: PokerSnapshot) => void; + readonly handLabel: string | null; + readonly bestCardKeys: ReadonlySet; + readonly actionProps: FeltCanvasActionProps; + readonly onActionFeedback: () => void; + readonly popups: readonly ActivePokerPopup[]; + readonly fullscreen?: boolean; +} + +function cardKey(card: Card): string { + return `${card.rank}-${card.suit}`; +} + +function signedAmount(value: number): string { + return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`; +} + +function PlacementBoard({ view }: { readonly view: SeatedSnapshot["you"] }) { + const placements = [...(view.finalPlacements ?? [])].sort((a, b) => a.placement - b.placement); + + return ( + + + Final placements + + +
    + {placements.map((placement) => { + const player = view.players.find((item) => item.seatId === placement.seatId); + return ( +
  1. + {placement.placement}. + + {player?.name ?? `Seat ${placement.seatId + 1}`} + + + {player?.stack.toLocaleString() ?? "0"} + +
  2. + ); + })} +
+
+
+ ); +} + +export function PokerFeltCanvas({ + snapshot, + onSnapshot, + handLabel, + bestCardKeys, + actionProps, + onActionFeedback, + popups, + fullscreen = false, +}: PokerFeltCanvasProps) { + const view = snapshot.you; + const winnerSeats = new Set(view.showdown?.pots.flatMap((pot) => pot.winnerSeats) ?? []); + const result = view.showdown?.results.find((item) => item.seatId === view.viewerSeat); + const hasHint = handLabel !== null; + const opponents = view.players.filter((player) => player.seatId !== view.viewerSeat); + const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat); + const popupsForSeat = (seatId: number): readonly ActivePokerPopup[] => + popups.filter((popup) => popup.seatId === seatId && popup.tone !== "leave"); + const viewerPopups = popupsForSeat(view.viewerSeat); + const tableAnnouncements = popups.filter((popup) => popup.tone === "leave"); + + return ( +
+
+ {opponents.map((player: PublicPlayerView, opponentIndex) => { + const positionClass = seatPosition(opponentIndex, opponents.length); + return ( +
+ + {popupsForSeat(player.seatId).length > 0 && ( + + )} +
+ {player.seatId === view.buttonSeat && ( + + D + + )} + {player.seatId === view.smallBlindSeat && SB} + {player.seatId === view.bigBlindSeat && BB} +
+
+ ); + })} + +
+ +
+ Pot + + {view.potTotal.toLocaleString()} + +
+
+ {view.communityCards.length > 0 ? ( + view.communityCards.map((card, index) => { + const isBestCard = bestCardKeys.has(cardKey(card)); + return ( + + ); + }) + ) : ( + + Community cards + + )} +
+ {view.phase === "betting" && ( +
+ +
+ )} + + {(view.phase === "showdown" || view.phase === "hand-complete") && ( +
+

+ {view.phase === "showdown" ? "Showdown" : "Hand complete"} +

+

+ {result + ? `Your result: ${signedAmount(result.netChange)} chips` + : "The hand is settled."} +

+ {snapshot.nextHandAt && ( + + )} +
+ )} +
+ + {view.phase !== "game-complete" && view.yourHoleCards.length > 0 && ( +
+ {view.yourHoleCards.map((card, index) => ( + + ))} +
+ )} + + + + {viewerPopups.length > 0 && ( + + )} + + {tableAnnouncements.length > 0 && ( +
+ {tableAnnouncements.map((popup) => ( + + ))} +
+ )} + + {view.phase === "game-complete" && ( +
+ +
+ )} +
+ + +
+ ); +} diff --git a/src/components/frontend/minigames/poker/PokerGameTable.tsx b/src/components/frontend/minigames/poker/PokerGameTable.tsx index dd35b3a..1acd100 100644 --- a/src/components/frontend/minigames/poker/PokerGameTable.tsx +++ b/src/components/frontend/minigames/poker/PokerGameTable.tsx @@ -3,52 +3,26 @@ import { createPortal } from "react-dom"; import { useEffect, useMemo, useState } from "react"; import { Maximize2, Minimize2 } from "lucide-react"; -import { cn } from "@/lib/utils"; -import type { Card, PokerPhase, PublicPlayerView } from "@/lib/minigames/poker"; +import type { PokerPhase } from "@/lib/minigames/poker"; import { describeHandStrength } from "@/lib/minigames/poker/handHint"; import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Card as UiCard, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { BettingControls } from "./BettingControls"; -import { CardView } from "./CardView"; -import { PokerClock } from "./PokerClock"; -import { PokerSeat } from "./PokerSeat"; +import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { PokerCardStyleToggle } from "./PokerCardStyleToggle"; +import { PokerFeltCanvas, type SeatedSnapshot } from "./PokerFeltCanvas"; +import { PokerSoundToggle } from "./PokerSoundToggle"; +import type { PokerSoundControls } from "./usePokerSounds"; +import type { ActivePokerPopup } from "./usePokerPopups"; import { RenameDialogGuard } from "./RenameDialogGuard"; -import { seatPosition } from "./seatPosition"; - -type SeatedSnapshot = PokerSnapshot & { - readonly you: NonNullable; -}; interface PokerGameTableProps { readonly snapshot: SeatedSnapshot; readonly onSnapshot: (snapshot: PokerSnapshot) => void; readonly onStart: () => Promise; readonly onLeave: () => Promise; -} - -interface FeltCanvasProps { - readonly snapshot: SeatedSnapshot; - readonly onSnapshot: (snapshot: PokerSnapshot) => void; - readonly handLabel: string | null; - readonly bestCardKeys: ReadonlySet; - readonly fullscreen?: boolean; -} - -interface TableActionButtonsProps { - readonly view: SeatedSnapshot["you"]; - readonly hostSeatId: number | null; - readonly starting: boolean; - readonly leaving: boolean; - readonly onStart: () => void; - readonly onLeave: () => void; + readonly sound: PokerSoundControls; + readonly popups: readonly ActivePokerPopup[]; } const PHASE_LABELS: Record = { @@ -58,197 +32,14 @@ const PHASE_LABELS: Record = { "game-complete": "Game complete", }; -function cardKey(card: Card): string { - return `${card.rank}-${card.suit}`; -} - -function signedAmount(value: number): string { - return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`; -} - -function PlacementBoard({ view }: { readonly view: SeatedSnapshot["you"] }) { - const placements = [...(view.finalPlacements ?? [])].sort((a, b) => a.placement - b.placement); - return ( - - - Final placements - - -
    - {placements.map((placement) => { - const player = view.players.find((item) => item.seatId === placement.seatId); - return ( -
  1. - {placement.placement}. - - {player?.name ?? `Seat ${placement.seatId + 1}`} - - - {player?.stack.toLocaleString() ?? "0"} - -
  2. - ); - })} -
-
-
- ); -} - -function TableActionButtons({ - view, - hostSeatId, - starting, - leaving, - onStart, - onLeave, -}: TableActionButtonsProps) { - return ( - <> - {view.phase === "game-complete" && view.viewerSeat === hostSeatId && ( - - )} - - - ); -} - -function FeltCanvas({ +export function PokerGameTable({ snapshot, onSnapshot, - handLabel, - bestCardKeys, - fullscreen = false, -}: FeltCanvasProps) { - const view = snapshot.you; - const winnerSeats = new Set(view.showdown?.pots.flatMap((pot) => pot.winnerSeats) ?? []); - const result = view.showdown?.results.find((item) => item.seatId === view.viewerSeat); - const showHoleCards = view.phase !== "hand-complete" && view.phase !== "game-complete"; - const hasHint = handLabel !== null; - - return ( -
- {view.players.map((player: PublicPlayerView) => { - const isViewer = player.seatId === view.viewerSeat; - return ( -
- -
- {player.seatId === view.buttonSeat && ( - - D - - )} - {player.seatId === view.smallBlindSeat && SB} - {player.seatId === view.bigBlindSeat && BB} -
-
- ); - })} - -
- {view.phase === "betting" && ( - - )} -
- {view.communityCards.length > 0 ? ( - view.communityCards.map((card, index) => { - const isBestCard = bestCardKeys.has(cardKey(card)); - return ( - - ); - }) - ) : ( - - Community cards - - )} -
-
- Pot - - {view.potTotal.toLocaleString()} - -
- - {(view.phase === "showdown" || view.phase === "hand-complete") && ( -
-

- {view.phase === "showdown" ? "Showdown" : "Hand complete"} -

-

- {result - ? `Your result: ${signedAmount(result.netChange)} chips` - : "The hand is settled."} -

- {snapshot.nextHandAt && ( - - )} -
- )} -
- - {view.phase !== "game-complete" && ( -
- -
- )} - - {view.phase === "game-complete" && ( -
- -
- )} -
- ); -} - -export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: PokerGameTableProps) { + onStart, + onLeave, + sound, + popups, +}: PokerGameTableProps) { const [starting, setStarting] = useState(false); const [leaving, setLeaving] = useState(false); const [fullscreen, setFullscreen] = useState(false); @@ -292,11 +83,19 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker const tableName = snapshot.name ?? `Table ${snapshot.tableId}`; const feltCanvas = ( - void start(), + onLeave: () => void leave(), + }} + onActionFeedback={sound.playUiClick} + popups={popups} fullscreen={fullscreen} /> ); @@ -312,11 +111,13 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker {PHASE_LABELS[view.phase]}
- {view.phase === "game-complete" && view.viewerSeat === snapshot.hostSeatId && ( - - )} + + - + {view.phase !== "game-complete" && ( + + )}
{feltCanvas} @@ -360,6 +168,13 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker v{snapshot.version} + + + {view.phase !== "game-complete" && ( + + )}
{feltCanvas} - - void start()} - onLeave={() => void leave()} - /> - ); } diff --git a/src/components/frontend/minigames/poker/PokerGuide.tsx b/src/components/frontend/minigames/poker/PokerGuide.tsx index b9b6306..8536331 100644 --- a/src/components/frontend/minigames/poker/PokerGuide.tsx +++ b/src/components/frontend/minigames/poker/PokerGuide.tsx @@ -17,6 +17,7 @@ interface PokerRanking { readonly name: string; readonly example: readonly Card[]; readonly description: string; + readonly color?: string; } function card(rank: Rank, suit: Suit): Card { @@ -48,31 +49,37 @@ const RANKINGS: readonly PokerRanking[] = [ name: "Straight", example: [card(5, "s"), card(6, "h"), card(7, "d"), card(8, "c"), card(9, "s")], description: "Five cards run in rank order.", + color: "#ffff00", }, { name: "Flush", example: [card(14, "h"), card(10, "h"), card(7, "h"), card(4, "h"), card(2, "h")], description: "Five cards share the same suit.", + color: "#ffff00", }, { name: "Full house", example: [card(13, "s"), card(13, "h"), card(13, "d"), card(8, "c"), card(8, "s")], description: "Three of one rank and two of another.", + color: "#ffff00", }, { name: "Four of a kind", example: [card(3, "s"), card(3, "h"), card(3, "d"), card(3, "c"), card(14, "s")], description: "Four cards share the same rank.", + color: "#ffff00", }, { name: "Straight flush", example: [card(5, "s"), card(6, "s"), card(7, "s"), card(8, "s"), card(9, "s")], description: "Five cards run in order and share a suit.", + color: "#ffff00", }, { name: "Royal flush", example: [card(10, "h"), card(11, "h"), card(12, "h"), card(13, "h"), card(14, "h")], description: "The ace-high straight flush, the strongest hand.", + color: "#ffff00", }, ]; @@ -97,11 +104,13 @@ export function PokerGuide() {

Hand rankings

    {RANKINGS.map((ranking, index) => ( -
  1. +
  2. {index + 1} -
    +
    {ranking.name}
    @@ -115,7 +124,7 @@ export function PokerGuide() { ))}
    -

    {ranking.description}

    +

    {ranking.description}

  3. ))} diff --git a/src/components/frontend/minigames/poker/PokerSeat.tsx b/src/components/frontend/minigames/poker/PokerSeat.tsx index 56f8875..80a9495 100644 --- a/src/components/frontend/minigames/poker/PokerSeat.tsx +++ b/src/components/frontend/minigames/poker/PokerSeat.tsx @@ -1,8 +1,8 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; -import type { Card, PublicPlayerView } from "@/lib/minigames/poker"; -import { Badge } from "@/components/ui/badge"; +import type { PublicPlayerView } from "@/lib/minigames/poker"; import { CardView } from "./CardView"; interface PokerSeatProps { @@ -11,14 +11,11 @@ interface PokerSeatProps { readonly isActing: boolean; readonly isWinner: boolean; readonly showHoleCards: boolean; - readonly handLabel?: string | null; - readonly cardKeySet?: ReadonlySet; - readonly compact?: boolean; } -function cardKey(card: Card): string { - return `${card.rank}-${card.suit}`; -} +type StackFlash = "up" | "down" | null; + +const STACK_FLASH_MS = 900; export function PokerSeat({ player, @@ -26,77 +23,104 @@ export function PokerSeat({ isActing, isWinner, showHoleCards, - handLabel = null, - cardKeySet, - compact = false, }: PokerSeatProps) { - const cards = showHoleCards ? player.holeCards : null; - const cardCount = player.cardCount > 0 ? player.cardCount : 2; - const faceCards = cards ?? Array.from({ length: cardCount }, () => null); + const revealed = showHoleCards ? player.holeCards : null; + const inHand = showHoleCards && !player.folded && !player.eliminated && !player.left; + const initial = player.name.trim().charAt(0).toUpperCase() || "?"; + const statusLabel = player.left + ? "Left" + : isActing + ? "Acting" + : player.folded + ? "Folded" + : player.allIn + ? "All-in" + : player.hasActed + ? "Acted" + : null; + const [stackFlash, setStackFlash] = useState(null); + const previousStackRef = useRef(null); + + useEffect(() => { + const previousStack = previousStackRef.current; + previousStackRef.current = player.stack; + if (previousStack === null || previousStack === player.stack) { + return undefined; + } + setStackFlash(player.stack > previousStack ? "up" : "down"); + const timer = setTimeout(() => setStackFlash(null), STACK_FLASH_MS); + return () => clearTimeout(timer); + }, [player.stack]); return (
    -
    -
    -

    - {player.name} - {isCurrentUser && (you)} -

    -

    - Stack {player.stack.toLocaleString()} -

    -
    -
    - {player.allIn && All-in} - {player.folded && Folded} - {isWinner && Winner} -
    + +
    +

    + {player.name} + {isCurrentUser && (you)} +

    +

    +

    + {(statusLabel || isWinner) && ( +
    + {statusLabel && {statusLabel}} + {isWinner && ( + Winner + )} +
    + )}
    - {handLabel && ( - - You have: {handLabel} - - )} - -
    -
    - {faceCards.map((card, index) => ( - + {revealed ? ( +
    + {revealed.map((card, index) => ( + ))}
    -
    -

    - {player.committed > 0 ? `Bet ${player.committed.toLocaleString()}` : "No bet"} -

    - {isActing &&

    Acting

    } + ) : ( + inHand && ( +
    +
    + ) + )} + + {player.committed > 0 && ( +
    +
    -
    + )}
    ); } diff --git a/src/components/frontend/minigames/poker/PokerTable.tsx b/src/components/frontend/minigames/poker/PokerTable.tsx index d66c146..5c708b0 100644 --- a/src/components/frontend/minigames/poker/PokerTable.tsx +++ b/src/components/frontend/minigames/poker/PokerTable.tsx @@ -3,12 +3,16 @@ import { useState } from "react"; import { cn } from "@/lib/utils"; import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions"; +import { MIN_PLAYERS } from "@/lib/minigames/poker"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { PokerCardStyleProvider } from "./cardStyle"; import { PokerGameTable } from "./PokerGameTable"; import { RenameTableDialog } from "./RenameTableDialog"; +import { usePokerPopups } from "./usePokerPopups"; +import { usePokerSounds } from "./usePokerSounds"; import { seatPosition } from "./seatPosition"; interface PokerTableProps { @@ -53,7 +57,10 @@ function WaitingTable({ const viewerSeat = snapshot.seats.findIndex((id) => String(id) === String(currentUserId)); const seated = viewerSeat >= 0; const full = snapshot.seats.length > 0 && snapshot.seats.every((id) => id !== null); + const seatedCount = snapshot.seats.filter((id) => id !== null).length; + const enoughPlayers = seatedCount >= MIN_PLAYERS; const isHost = seated && snapshot.hostSeatId === viewerSeat; + const opponentCount = snapshot.seats.length - (seated ? 1 : 0); const start = async () => { setStarting(true); @@ -92,18 +99,34 @@ function WaitingTable({ Waiting room -
    +
    {snapshot.seats.map((userId, seatId) => (
    previousSeatId !== viewerSeat).length, + opponentCount, + ), userId !== null && "poker-waiting-seat-occupied", )} > - Seat {seatId + 1} - + + Seat {seatId + 1} + {userId === null ? "Open seat" : String(userId) === String(currentUserId) @@ -126,8 +149,16 @@ function WaitingTable({ {seated ? ( <> {isHost && ( - )}