Compare commits
8 commits
618f884527
...
074fdb02bf
| Author | SHA1 | Date | |
|---|---|---|---|
| 074fdb02bf | |||
| 2691bb05ec | |||
| f81d978f88 | |||
| 169f938b05 | |||
| 0ddc090acb | |||
| df90a34c99 | |||
| 90070abb2e | |||
| c4b19bf8e5 |
12 changed files with 349 additions and 90 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -1,5 +1,19 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
# v0.2.38
|
||||||
|
|
||||||
|
Blackjack arrives as a full table game with its own economy: every minigame now feeds a persistent chip bank you spend on buy-ins at the card tables. Poker gains named tables, popup history, and dev controls; mission briefings and side ops move to a proper rich text editor, and rank promotions get a ceiling-based authority model.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Blackjack**: take a seat at a four-seat oval felt with betting, insurance, double, and split. Chip piles stack on the rail by denomination, wins fly chips down to your pile while losses float up to the dealer, and fullscreen mode keeps the felt centered at any window size. Deal whooshes, turn chimes, and win stingers come with a sound toggle that remembers your choice.
|
||||||
|
- **Chip bank**: minigame XP now earns play chips (10 per XP), spent as buy-ins at the poker and blackjack tables. The balance card stays pinned in the corner while you scroll, pops the delta when your balance moves, flashes green or red with the direction, and plays a rising or falling chime. A daily top-up is there to claim when you dip low.
|
||||||
|
- **Poker upgrades**: tables carry proper names, action popups keep a replayable history, and a dev panel exposes table controls for testing.
|
||||||
|
- **Respawn tickets**: mission attendance converts into respawn tickets, settled automatically after each op with balances that accumulate over time.
|
||||||
|
- **Rank promotions**: promotion authority now derives from your rank's ceiling instead of a flat staff check, and command staff can promote directly from a member's profile.
|
||||||
|
- **Mission briefings**: briefings and side-op narratives use a rich text editor with a formatting toolbar, inline wikilinks, and live preview; comms bodies render with matching styles.
|
||||||
|
- **Voiceover**: recordings gain a spoiler gate so unreleased content stays hidden until its time, plus clause-aware pause pacing.
|
||||||
|
---
|
||||||
# v0.2.36
|
# v0.2.36
|
||||||
|
|
||||||
Poker takes the spotlight in this stretch: a rebuilt table, synthesized sound, card skins, and glowing hand ranks. Alongside it, the awards program learns advancement series, and poker placement XP is paused while payouts get reworked; placements, hands won, and best chips still record as normal.
|
Poker takes the spotlight in this stretch: a rebuilt table, synthesized sound, card skins, and glowing hand ranks. Alongside it, the awards program learns advancement series, and poker placement XP is paused while payouts get reworked; placements, hands won, and best chips still record as normal.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.38",
|
"version": "0.2.39",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1378,13 +1378,128 @@
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.poker-felt-fullscreen {
|
/* Blackjack seats share the poker seat panel CSS, whose commit pill and hand
|
||||||
min-height: max(76vh, 42rem);
|
reveal are absolutely positioned via poker-only arc classes that blackjack
|
||||||
border-radius: 50% / 48%;
|
slots never carry. Keep the pill in the panel row and push corner-seat
|
||||||
|
reveals outward, clear of the dealer block. */
|
||||||
|
.blackjack-felt .poker-seat-commit {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poker-felt-fullscreen::after {
|
.blackjack-felt .blackjack-seat-arc-corner-right .poker-seat-reveal {
|
||||||
border-radius: 50% / 46%;
|
left: calc(50% + 4.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blackjack-felt .blackjack-seat-arc-corner-left .poker-seat-reveal {
|
||||||
|
left: calc(50% - 4.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top-anchor the log below the dealer block (bottom edge reaches ~44% with
|
||||||
|
the turn clock) so the newest line can never climb over the time bar. */
|
||||||
|
.blackjack-felt .poker-action-log {
|
||||||
|
top: 46%;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same fix for poker: bottom-anchoring let the newest line climb into the
|
||||||
|
center block's turn clock. The clock's bottom edge sits around 65% of the
|
||||||
|
4:3 felt during betting, so the log is top-anchored at 66.5% and capped
|
||||||
|
to three visible lines (about 0.95rem each plus gaps) so it ends before
|
||||||
|
the viewer card spot; the mask fades the capped tail. */
|
||||||
|
.poker-felt .poker-action-log {
|
||||||
|
top: 66.5%;
|
||||||
|
bottom: auto;
|
||||||
|
max-height: 2.95rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Poker chip flight: every chip starts stacked at one origin and launches
|
||||||
|
one after another (130ms stagger, fill-mode both holds unlaunched chips
|
||||||
|
in the pile). Wins drop from the pot plaque (~40% of the felt) down to
|
||||||
|
the viewer card spot (bottom 7rem, so about 14rem of travel); losses fly
|
||||||
|
the reverse leg from the card spot up to the pot. Chips are the shared
|
||||||
|
.bj-chip pieces; the geometry is poker-scoped because blackjack flies
|
||||||
|
between different anchors. */
|
||||||
|
.poker-chip-flight {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 35;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 0.95rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poker-chip-flight-win {
|
||||||
|
top: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poker-chip-flight-loss {
|
||||||
|
bottom: 7.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poker-chip-flight .bj-chip {
|
||||||
|
bottom: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poker-chip-flight-win .bj-chip {
|
||||||
|
animation: poker-chip-fly-win 700ms ease-in both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poker-chip-flight-loss .bj-chip {
|
||||||
|
animation: poker-chip-fly-loss 700ms ease-in both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes poker-chip-fly-win {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14rem) scale(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes poker-chip-fly-loss {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-14rem) scale(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.poker-chip-flight {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fullscreen contain-fit: absolute with zero insets + auto margins sizes the
|
||||||
|
felt to the largest 4:3 rectangle inside the viewport area (the same rules
|
||||||
|
browsers use for replaced elements), so it can never collapse to content
|
||||||
|
size and never overflow. Must stay after .poker-felt-table to win the
|
||||||
|
width tie. */
|
||||||
|
.poker-felt-fullscreen {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poker-seat-panel {
|
.poker-seat-panel {
|
||||||
|
|
@ -1633,37 +1748,12 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poker-seat-commit-chip {
|
.poker-seat-commit-label {
|
||||||
position: relative;
|
color: rgb(255 255 255 / 55%);
|
||||||
display: inline-block;
|
font-size: 0.65rem;
|
||||||
width: 1.1rem;
|
font-weight: 500;
|
||||||
height: 0.72rem;
|
letter-spacing: 0.16em;
|
||||||
border: 1px solid #d6b36a;
|
text-transform: uppercase;
|
||||||
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 {
|
.poker-seat-arc-left .poker-seat-commit {
|
||||||
|
|
@ -1769,6 +1859,14 @@
|
||||||
0 0.25rem 0.5rem rgb(0 0 0 / 24%);
|
0 0.25rem 0.5rem rgb(0 0 0 / 24%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Big pots break down into up to seven denomination piles; let the pile row
|
||||||
|
wrap so the plaque can never outgrow the center block on narrow felts. */
|
||||||
|
.poker-pot-plaque .bj-chip-stack {
|
||||||
|
max-width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.poker-pot-plaque .poker-console-kicker {
|
.poker-pot-plaque .poker-console-kicker {
|
||||||
color: #f3d28b;
|
color: #f3d28b;
|
||||||
}
|
}
|
||||||
|
|
@ -1862,6 +1960,15 @@
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Below-felt variant: on narrow screens the toolbar renders in normal flow
|
||||||
|
under the felt because the felt's overflow-hidden clips the overlay row
|
||||||
|
there; static positioning cancels the overlay anchor. */
|
||||||
|
.poker-action-toolbar-below {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
width: min(30rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.poker-action-toolbar .poker-betting-controls-compact {
|
.poker-action-toolbar .poker-betting-controls-compact {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|
@ -2794,22 +2901,34 @@
|
||||||
exact number always rides alongside in a text label. Pile heights are set
|
exact number always rides alongside in a text label. Pile heights are set
|
||||||
inline from the chip count so nothing clips or overlaps. */
|
inline from the chip count so nothing clips or overlaps. */
|
||||||
.bj-chip-stack {
|
.bj-chip-stack {
|
||||||
|
--bj-chip-w: 1.6rem;
|
||||||
|
--bj-chip-h: 0.95rem;
|
||||||
|
--bj-chip-step: 0.3rem;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact piles (sm) share the same markup with smaller discs so dense
|
||||||
|
surfaces like poker's bet pills and console readouts keep their height. */
|
||||||
|
.bj-chip-stack-sm {
|
||||||
|
--bj-chip-w: 1.15rem;
|
||||||
|
--bj-chip-h: 0.68rem;
|
||||||
|
--bj-chip-step: 0.2rem;
|
||||||
|
gap: 0.22rem;
|
||||||
|
}
|
||||||
|
|
||||||
.bj-chip-pile {
|
.bj-chip-pile {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1.6rem;
|
width: var(--bj-chip-w, 1.6rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bj-chip {
|
.bj-chip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 1.6rem;
|
width: var(--bj-chip-w, 1.6rem);
|
||||||
height: 0.95rem;
|
height: var(--bj-chip-h, 0.95rem);
|
||||||
border: 2px dashed rgb(255 255 255 / 72%);
|
border: 2px dashed rgb(255 255 255 / 72%);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
|
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { BlackjackChipStack } from "./BlackjackChipStack";
|
import { BlackjackChipStack } from "./BlackjackChipStack";
|
||||||
import { BlackjackControls } from "./BlackjackControls";
|
import { BlackjackControls } from "./BlackjackControls";
|
||||||
|
|
||||||
|
|
@ -12,18 +13,22 @@ interface BlackjackActionToolbarProps {
|
||||||
readonly snapshot: SeatedSnapshot;
|
readonly snapshot: SeatedSnapshot;
|
||||||
readonly onSnapshot: (snapshot: BlackjackSnapshot) => void;
|
readonly onSnapshot: (snapshot: BlackjackSnapshot) => void;
|
||||||
readonly onActionFeedback: () => void;
|
readonly onActionFeedback: () => void;
|
||||||
|
readonly below?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal felt overlay: one bottom-centered column holding the bankroll
|
* Minimal felt overlay: one bottom-centered column holding the bankroll
|
||||||
* pill plus either the waiting pill or the acting player's controls.
|
* pill plus either the waiting pill or the acting player's controls.
|
||||||
* Rendering them as a single stack keeps the pair collision-free at every
|
* Rendering them as a single stack keeps the pair collision-free at every
|
||||||
* phase; on a settled round only the bankroll pill remains.
|
* phase; on a settled round only the bankroll pill remains. With `below`,
|
||||||
|
* the same stack renders in normal flow under the felt so narrow screens
|
||||||
|
* never clip it.
|
||||||
*/
|
*/
|
||||||
export function BlackjackActionToolbar({
|
export function BlackjackActionToolbar({
|
||||||
snapshot,
|
snapshot,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
onActionFeedback,
|
onActionFeedback,
|
||||||
|
below = false,
|
||||||
}: BlackjackActionToolbarProps) {
|
}: BlackjackActionToolbarProps) {
|
||||||
const view = snapshot.you;
|
const view = snapshot.you;
|
||||||
const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat);
|
const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat);
|
||||||
|
|
@ -58,14 +63,21 @@ export function BlackjackActionToolbar({
|
||||||
|
|
||||||
if (settled) {
|
if (settled) {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-5 z-30 flex justify-center">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex justify-center",
|
||||||
|
below
|
||||||
|
? "pointer-events-none mt-3"
|
||||||
|
: "pointer-events-none absolute inset-x-0 bottom-5 z-30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{bankrollPill}
|
{bankrollPill}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-x-0 bottom-5 z-30 flex justify-center">
|
<div className={cn("flex justify-center", below ? "mt-3" : "absolute inset-x-0 bottom-5 z-30")}>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
{bankrollPill}
|
{bankrollPill}
|
||||||
{!view.yourTurn ? (
|
{!view.yourTurn ? (
|
||||||
|
|
@ -74,9 +86,7 @@ export function BlackjackActionToolbar({
|
||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const actingPlayer = view.players.find(
|
const actingPlayer = view.players.find((player) => player.seatId === view.activeSeat);
|
||||||
(player) => player.seatId === view.activeSeat,
|
|
||||||
);
|
|
||||||
return actingPlayer ? `Waiting for ${actingPlayer.name}` : "Waiting for the table";
|
return actingPlayer ? `Waiting for ${actingPlayer.name}` : "Waiting for the table";
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
||||||
/**
|
/**
|
||||||
* Renders an amount as a pile of casino chips: denomination-colored CSS
|
* Renders an amount as a pile of casino chips: denomination-colored CSS
|
||||||
* discs stacked into piles, popping in as the amount grows. Purely visual;
|
* discs stacked into piles, popping in as the amount grows. Purely visual;
|
||||||
* the exact number always rides alongside in a text label.
|
* the exact number always rides alongside in a text label. `size="sm"`
|
||||||
|
* renders smaller discs for dense surfaces (poker bet pills, console
|
||||||
|
* readouts) without forking the markup.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface ChipDenomination {
|
interface ChipDenomination {
|
||||||
|
|
@ -55,10 +57,6 @@ export function chipFlightChips(amount: number, maxChips = 8): FlightChip[] {
|
||||||
return chips;
|
return chips;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Must match the .bj-chip height + vertical step in styles.css. */
|
|
||||||
const CHIP_HEIGHT_REM = 0.95;
|
|
||||||
const CHIP_STEP_REM = 0.3;
|
|
||||||
|
|
||||||
interface ChipPile {
|
interface ChipPile {
|
||||||
readonly key: string;
|
readonly key: string;
|
||||||
readonly tone: string;
|
readonly tone: string;
|
||||||
|
|
@ -96,27 +94,37 @@ function pileUp(amount: number): ChipPile[] {
|
||||||
export function BlackjackChipStack({
|
export function BlackjackChipStack({
|
||||||
amount,
|
amount,
|
||||||
className,
|
className,
|
||||||
|
size = "md",
|
||||||
}: {
|
}: {
|
||||||
readonly amount: number;
|
readonly amount: number;
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
|
readonly size?: "md" | "sm";
|
||||||
}) {
|
}) {
|
||||||
if (amount <= 0) return null;
|
if (amount <= 0) return null;
|
||||||
const piles = pileUp(amount);
|
const piles = pileUp(amount);
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={150}>
|
<TooltipProvider delayDuration={150}>
|
||||||
<span className={cn("bj-chip-stack", className)} aria-hidden="true">
|
<span
|
||||||
|
className={cn("bj-chip-stack", size === "sm" && "bj-chip-stack-sm", className)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
{piles.map((pile) => (
|
{piles.map((pile) => (
|
||||||
<Tooltip key={pile.key}>
|
<Tooltip key={pile.key}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
className="bj-chip-pile"
|
className="bj-chip-pile"
|
||||||
style={{ height: `${CHIP_HEIGHT_REM + (pile.count - 1) * CHIP_STEP_REM}rem` }}
|
style={{
|
||||||
|
height: `calc(var(--bj-chip-h) + ${pile.count - 1} * var(--bj-chip-step))`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{Array.from({ length: pile.count }, (_, chipIndex) => (
|
{Array.from({ length: pile.count }, (_, chipIndex) => (
|
||||||
<span
|
<span
|
||||||
key={`${pile.key}-${chipIndex}`}
|
key={`${pile.key}-${chipIndex}`}
|
||||||
className={cn("bj-chip", pile.tone)}
|
className={cn("bj-chip", pile.tone)}
|
||||||
style={{ bottom: `${chipIndex * CHIP_STEP_REM}rem`, zIndex: chipIndex + 1 }}
|
style={{
|
||||||
|
bottom: `calc(${chipIndex} * var(--bj-chip-step))`,
|
||||||
|
zIndex: chipIndex + 1,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,17 @@ export function BlackjackFeltCanvas({
|
||||||
const tableAnnouncements = popups.filter((popup) => popup.tone === "leave");
|
const tableAnnouncements = popups.filter((popup) => popup.tone === "leave");
|
||||||
const liveTurn = view.phase !== "round-complete";
|
const liveTurn = view.phase !== "round-complete";
|
||||||
|
|
||||||
|
// Narrow screens render the action toolbar below the felt in flow: the
|
||||||
|
// overlay pill row is wider than the felt there and the felt clips it.
|
||||||
|
const [toolbarBelow, setToolbarBelow] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia("(max-width: 640px)");
|
||||||
|
const update = () => setToolbarBelow(query.matches);
|
||||||
|
update();
|
||||||
|
query.addEventListener("change", update);
|
||||||
|
return () => query.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Chip flight: on the settle transition, chips launch one after another
|
// Chip flight: on the settle transition, chips launch one after another
|
||||||
// from the hand spot with denomination colors matching the money moved:
|
// from the hand spot with denomination colors matching the money moved:
|
||||||
// payouts (and pushes) toward the bankroll pill, lost bets up to the
|
// payouts (and pushes) toward the bankroll pill, lost bets up to the
|
||||||
|
|
@ -264,7 +275,7 @@ export function BlackjackFeltCanvas({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!spectator && (
|
{!spectator && !toolbarBelow && (
|
||||||
<BlackjackActionToolbar
|
<BlackjackActionToolbar
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
onSnapshot={onSnapshot}
|
onSnapshot={onSnapshot}
|
||||||
|
|
@ -368,6 +379,15 @@ export function BlackjackFeltCanvas({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!spectator && toolbarBelow && (
|
||||||
|
<BlackjackActionToolbar
|
||||||
|
snapshot={snapshot}
|
||||||
|
onSnapshot={onSnapshot}
|
||||||
|
onActionFeedback={onActionFeedback}
|
||||||
|
below
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,9 @@ export function BlackjackSeat({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{player.hands.map((hand, handIndex) => (
|
{player.hands
|
||||||
|
.filter((hand) => hand.cards.length > 0)
|
||||||
|
.map((hand, handIndex) => (
|
||||||
<div
|
<div
|
||||||
key={`${player.seatId}-hand-${handIndex}`}
|
key={`${player.seatId}-hand-${handIndex}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type { PublicPlayerView } from "@/lib/minigames/poker";
|
||||||
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { BlackjackChipStack as ChipStack } from "../blackjack/BlackjackChipStack";
|
||||||
import { PokerClock } from "./PokerClock";
|
import { PokerClock } from "./PokerClock";
|
||||||
|
|
||||||
type SeatedSnapshot = PokerSnapshot & {
|
type SeatedSnapshot = PokerSnapshot & {
|
||||||
|
|
@ -181,7 +182,11 @@ export function PokerActionConsole({
|
||||||
<div className="poker-console-economy">
|
<div className="poker-console-economy">
|
||||||
{!spectator && (
|
{!spectator && (
|
||||||
<div className="poker-chip-readout">
|
<div className="poker-chip-readout">
|
||||||
|
{chipsInPlay > 0 ? (
|
||||||
|
<ChipStack amount={chipsInPlay} size="sm" />
|
||||||
|
) : (
|
||||||
<span className="poker-chip-stack" aria-hidden="true" />
|
<span className="poker-chip-stack" aria-hidden="true" />
|
||||||
|
)}
|
||||||
<span className="poker-console-readout-copy">
|
<span className="poker-console-readout-copy">
|
||||||
<span className="poker-console-kicker">Chips in play</span>
|
<span className="poker-console-kicker">Chips in play</span>
|
||||||
<strong className="font-mono text-sm tabular-nums">
|
<strong className="font-mono text-sm tabular-nums">
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,14 @@ interface PokerActionToolbarProps {
|
||||||
readonly snapshot: SeatedSnapshot;
|
readonly snapshot: SeatedSnapshot;
|
||||||
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
||||||
readonly onActionFeedback: () => void;
|
readonly onActionFeedback: () => void;
|
||||||
|
readonly below?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PokerActionToolbar({
|
export function PokerActionToolbar({
|
||||||
snapshot,
|
snapshot,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
onActionFeedback,
|
onActionFeedback,
|
||||||
|
below = false,
|
||||||
}: PokerActionToolbarProps) {
|
}: PokerActionToolbarProps) {
|
||||||
const view = snapshot.you;
|
const view = snapshot.you;
|
||||||
|
|
||||||
|
|
@ -25,14 +27,20 @@ export function PokerActionToolbar({
|
||||||
if (!view.yourTurn) {
|
if (!view.yourTurn) {
|
||||||
const actingPlayer = view.players.find((player) => player.seatId === view.toActSeat);
|
const actingPlayer = view.players.find((player) => player.seatId === view.toActSeat);
|
||||||
return (
|
return (
|
||||||
<div className="poker-action-toolbar poker-action-toolbar-waiting" role="status">
|
<div
|
||||||
|
className={`poker-action-toolbar poker-action-toolbar-waiting${below ? " poker-action-toolbar-below" : ""}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
{actingPlayer ? `Waiting for ${actingPlayer.name}` : "Waiting for the table"}
|
{actingPlayer ? `Waiting for ${actingPlayer.name}` : "Waiting for the table"}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="poker-action-toolbar" aria-label="Poker actions">
|
<div
|
||||||
|
className={`poker-action-toolbar${below ? " poker-action-toolbar-below" : ""}`}
|
||||||
|
aria-label="Poker actions"
|
||||||
|
>
|
||||||
<BettingControls
|
<BettingControls
|
||||||
tableId={snapshot.tableId}
|
tableId={snapshot.tableId}
|
||||||
snapshotVersion={snapshot.version}
|
snapshotVersion={snapshot.version}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { BlackjackChipStack as ChipStack, chipFlightChips, type FlightChip } from "../blackjack/BlackjackChipStack";
|
||||||
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
|
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
|
||||||
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -110,12 +112,50 @@ export function PokerFeltCanvas({
|
||||||
const viewerPopups = popupsForSeat(view.viewerSeat);
|
const viewerPopups = popupsForSeat(view.viewerSeat);
|
||||||
const tableAnnouncements = popups.filter((popup) => popup.tone === "leave");
|
const tableAnnouncements = popups.filter((popup) => popup.tone === "leave");
|
||||||
|
|
||||||
|
// Narrow screens render the action toolbar below the felt in flow: the
|
||||||
|
// overlay row is wider than the felt there and the felt clips it.
|
||||||
|
const [toolbarBelow, setToolbarBelow] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia("(max-width: 640px)");
|
||||||
|
const update = () => setToolbarBelow(query.matches);
|
||||||
|
update();
|
||||||
|
query.addEventListener("change", update);
|
||||||
|
return () => query.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Chip flight: on the settle transition the viewer's net chips fly between
|
||||||
|
// the pot plaque (felt center) and the viewer card spot: winnings drop
|
||||||
|
// from the pot, lost stakes climb to it. The hand number guards against
|
||||||
|
// refiring while the snapshot settles through showdown and hand-complete.
|
||||||
|
const previousSettleHandRef = useRef<number | null>(null);
|
||||||
|
const [chipFlights, setChipFlights] = useState<
|
||||||
|
{ key: number; direction: "win" | "loss"; chips: FlightChip[] }[]
|
||||||
|
>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
const settling = view.phase === "showdown" || view.phase === "hand-complete";
|
||||||
|
if (!settling || !result || result.netChange === 0) {
|
||||||
|
previousSettleHandRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previousSettleHandRef.current === view.handNumber) return;
|
||||||
|
previousSettleHandRef.current = view.handNumber;
|
||||||
|
setChipFlights([
|
||||||
|
{
|
||||||
|
key: Date.now(),
|
||||||
|
direction: result.netChange > 0 ? "win" : "loss",
|
||||||
|
chips: chipFlightChips(Math.abs(result.netChange)),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const timer = setTimeout(() => setChipFlights([]), 2400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [view.phase, view.handNumber, result]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="poker-table-stage">
|
<div className="poker-table-stage">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"poker-felt-table relative min-h-[36rem] overflow-hidden p-4 sm:min-h-[42rem]",
|
"poker-felt-table poker-felt relative aspect-[4/3] overflow-hidden p-4",
|
||||||
fullscreen && "poker-felt-fullscreen",
|
fullscreen ? "poker-felt-fullscreen" : "min-h-[40rem] w-full max-w-[56rem]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{opponents.map((player: PublicPlayerView, opponentIndex) => {
|
{opponents.map((player: PublicPlayerView, opponentIndex) => {
|
||||||
|
|
@ -168,6 +208,7 @@ export function PokerFeltCanvas({
|
||||||
<PokerDeckOrigin />
|
<PokerDeckOrigin />
|
||||||
<div className="poker-pot-plaque">
|
<div className="poker-pot-plaque">
|
||||||
<span className="poker-console-kicker">Pot</span>
|
<span className="poker-console-kicker">Pot</span>
|
||||||
|
<ChipStack amount={view.potTotal} />
|
||||||
<strong className="font-mono text-lg tabular-nums">
|
<strong className="font-mono text-lg tabular-nums">
|
||||||
{view.potTotal.toLocaleString()}
|
{view.potTotal.toLocaleString()}
|
||||||
</strong>
|
</strong>
|
||||||
|
|
@ -252,7 +293,7 @@ export function PokerFeltCanvas({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!spectator && (
|
{!spectator && !toolbarBelow && (
|
||||||
<PokerActionToolbar
|
<PokerActionToolbar
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
onSnapshot={onSnapshot}
|
onSnapshot={onSnapshot}
|
||||||
|
|
@ -260,6 +301,27 @@ export function PokerFeltCanvas({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{chipFlights.map((flight) => (
|
||||||
|
<div
|
||||||
|
key={flight.key}
|
||||||
|
className={cn(
|
||||||
|
"poker-chip-flight",
|
||||||
|
flight.direction === "win"
|
||||||
|
? "poker-chip-flight-win"
|
||||||
|
: "poker-chip-flight-loss",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{flight.chips.map((chip, chipIndex) => (
|
||||||
|
<span
|
||||||
|
key={chip.key}
|
||||||
|
className={cn("bj-chip", chip.tone)}
|
||||||
|
style={{ animationDelay: `${chipIndex * 130}ms` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{viewerPopups.length > 0 && (
|
{viewerPopups.length > 0 && (
|
||||||
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
|
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
|
||||||
{viewerPopups.map((popup) => (
|
{viewerPopups.map((popup) => (
|
||||||
|
|
@ -287,6 +349,15 @@ export function PokerFeltCanvas({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!spectator && toolbarBelow && (
|
||||||
|
<PokerActionToolbar
|
||||||
|
snapshot={snapshot}
|
||||||
|
onSnapshot={onSnapshot}
|
||||||
|
onActionFeedback={onActionFeedback}
|
||||||
|
below
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<PokerActionConsole
|
<PokerActionConsole
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
handLabel={handLabel}
|
handLabel={handLabel}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ export function PokerGameTable({
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 flex flex-col gap-3 overflow-auto bg-black/92 p-4 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex flex-col gap-3 overflow-hidden bg-black/92 p-4 backdrop-blur-sm">
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border border-white/15 bg-black/60 p-3 backdrop-blur-sm">
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border border-white/15 bg-black/60 p-3 backdrop-blur-sm">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="truncate font-semibold text-white">{tableName}</span>
|
<span className="truncate font-semibold text-white">{tableName}</span>
|
||||||
|
|
@ -159,7 +159,7 @@ export function PokerGameTable({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{feltCanvas}
|
<div className="relative min-h-0 flex-1">{feltCanvas}</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { Shield } from "lucide-react";
|
import { Shield } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { PublicPlayerView } from "@/lib/minigames/poker";
|
import type { PublicPlayerView } from "@/lib/minigames/poker";
|
||||||
|
import { BlackjackChipStack as ChipStack } from "../blackjack/BlackjackChipStack";
|
||||||
import { CardView } from "./CardView";
|
import { CardView } from "./CardView";
|
||||||
|
|
||||||
interface PokerSeatProps {
|
interface PokerSeatProps {
|
||||||
|
|
@ -121,7 +122,8 @@ export function PokerSeat({
|
||||||
className="poker-seat-commit"
|
className="poker-seat-commit"
|
||||||
aria-label={`Current bet ${player.committed.toLocaleString()}`}
|
aria-label={`Current bet ${player.committed.toLocaleString()}`}
|
||||||
>
|
>
|
||||||
<span className="poker-seat-commit-chip" aria-hidden="true" />
|
<span className="poker-seat-commit-label">Bet</span>
|
||||||
|
<ChipStack amount={player.committed} size="sm" />
|
||||||
<span className="font-mono text-[0.75rem] tabular-nums">
|
<span className="font-mono text-[0.75rem] tabular-nums">
|
||||||
{player.committed.toLocaleString()}
|
{player.committed.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue