Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions web/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,12 @@
color: var(--ink-60);
}

.action-desc-line {
font-size: 12px;
color: var(--ink-70);
line-height: 1.3;
}

.action-buttons {
display: flex;
gap: 10px;
Expand All @@ -391,6 +397,29 @@
flex-wrap: wrap;
}

.action-button {
display: grid;
gap: 4px;
text-align: left;
align-items: start;
padding: 10px 14px;
white-space: normal;
max-width: 260px;
border-radius: 16px;
}

.action-button .action-label {
display: block;
}

.action-button .action-desc {
font-size: 11px;
color: var(--ink-60);
text-transform: none;
letter-spacing: 0.2px;
line-height: 1.3;
}

.action-choice-row .selected-action {
border-color: rgba(224, 164, 99, 0.9);
background: rgba(224, 164, 99, 0.2);
Expand Down
153 changes: 129 additions & 24 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ function App() {
<div className="action-title">
{selectedAction?.label ?? 'Select an action'}
</div>
{selectedAction && actionDescription(selectedAction) ? (
<div className="action-desc-line">
{actionDescription(selectedAction)}
</div>
) : null}
<div className="action-sub">
{state?.resolving_one_off
? 'Resolve or counter the one-off.'
Expand Down Expand Up @@ -364,16 +369,14 @@ function App() {
</div>
<div className="action-choice-row">
{actionChoices.map((action) => (
<button
<ActionButton
key={action.id}
className={`ghost ${
selectedActionId === action.id ? 'selected-action' : ''
}`}
onClick={() => handleActionSelect(action)}
action={action}
context="choice"
selected={selectedActionId === action.id}
disabled={Boolean(isGameOver)}
>
{action.label}
</button>
onClick={() => handleActionSelect(action)}
/>
))}
</div>
<div className="hand">
Expand Down Expand Up @@ -522,17 +525,13 @@ function App() {
<CardTile card={card} />
<div className="modal-actions">
{(sevenActionsByCard.get(card.id) ?? []).map((action) => (
<button
<ActionButton
key={action.id}
className={`ghost ${
selectedActionId === action.id
? 'selected-action'
: ''
}`}
action={action}
context="seven"
selected={selectedActionId === action.id}
onClick={() => handleActionSelect(action)}
>
{action.label}
</button>
/>
))}
</div>
</div>
Expand Down Expand Up @@ -578,15 +577,13 @@ function App() {
</div>
<div className="modal-actions">
{modalActions.map((action) => (
<button
<ActionButton
key={action.id}
className={`ghost ${
selectedActionId === action.id ? 'selected-action' : ''
}`}
action={action}
context="resolve"
selected={selectedActionId === action.id}
onClick={() => handleActionSelect(action)}
>
{action.label}
</button>
/>
))}
</div>
</div>
Expand Down Expand Up @@ -619,8 +616,33 @@ type CardTileProps = {
onClick?: () => void
}

type ActionButtonProps = {
action: ActionView
context: string
selected?: boolean
disabled?: boolean
onClick: () => void
}

const oneOffRanks = new Set(['ACE', 'THREE', 'FOUR', 'FIVE', 'SIX'])
const faceRanks = new Set(['JACK', 'QUEEN', 'KING', 'EIGHT'])
const oneOffDescriptions: Record<string, string> = {
ACE: 'Destroy all point cards on the field.',
TWO: 'Scrap a target royal or Glasses Eight.',
THREE: 'Take one card from the scrap pile.',
FOUR: 'Opponent discards two cards of their choice.',
FIVE: 'Discard one card, then draw up to three (max 8 in hand).',
SIX: 'Destroy all face cards (royals) on the field.',
SEVEN: 'Reveal the top two cards of the deck; choose one to play.',
NINE: "Return an opponent's field card to their hand (cannot play it next turn).",
TEN: 'No effect.',
}
const faceCardDescriptions: Record<string, string> = {
KING: 'Reduce points needed to win while in play.',
QUEEN: 'Protect your other cards from targeted effects.',
JACK: 'Steal a target point card while in play.',
EIGHT: "Reveal your opponent's hand while in play.",
}

function cardTag(card: CardView) {
if (card.purpose === 'POINTS') return 'points'
Expand Down Expand Up @@ -650,6 +672,61 @@ function rankShort(rank: string) {
return map[rank] ?? rank
}

function actionDescription(action: ActionView): string | null {
if (action.type === 'Draw') {
return 'Draw one card (max 8 in hand).'
}
if (action.type === 'Points') {
return 'Play for points equal to its rank.'
}
if (action.type === 'Scuttle') {
return 'Scrap an opponent point card with a higher card (suit breaks ties).'
}
if (action.type === 'Face Card') {
if (action.card?.rank && faceCardDescriptions[action.card.rank]) {
return faceCardDescriptions[action.card.rank]
}
return 'Play a royal for an ongoing effect.'
}
if (action.type === 'Jack') {
return 'Steal a target point card while in play.'
}
if (action.type === 'One-Off') {
if (action.card?.rank && oneOffDescriptions[action.card.rank]) {
return oneOffDescriptions[action.card.rank]
}
return "Trigger this card's one-off effect."
}
if (action.type === 'Counter') {
return 'Counter a one-off with a Two.'
}
if (action.type === 'Resolve') {
return 'Let the pending one-off resolve.'
}
if (action.type === 'Take From Discard') {
return 'Take one card from the scrap pile.'
}
if (action.type === 'Discard From Hand') {
return 'Discard a card from your hand.'
}
if (action.type === 'Discard Revealed') {
return 'Discard a revealed card.'
}
if (action.type === 'Request Stalemate') {
return 'Ask to end the game in a stalemate.'
}
if (action.type === 'Accept Stalemate') {
return 'Accept the stalemate request.'
}
if (action.type === 'Reject Stalemate') {
return 'Reject the stalemate request.'
}
if (action.type === 'Concede') {
return 'Concede the game.'
}
return null
}

function SuitIcon({ suit, size = 20 }: { suit: string; size?: number }) {
const suitProps = { size, strokeWidth: 2.5 }

Expand All @@ -667,6 +744,34 @@ function SuitIcon({ suit, size = 20 }: { suit: string; size?: number }) {
}
}

function ActionButton({
action,
context,
selected,
disabled,
onClick,
}: ActionButtonProps) {
const description = actionDescription(action)
const descId = description ? `action-desc-${context}-${action.id}` : undefined

return (
<button
className={`ghost action-button ${selected ? 'selected-action' : ''}`}
onClick={onClick}
disabled={disabled}
aria-label={action.label}
aria-describedby={descId}
>
<span className="action-label">{action.label}</span>
{description ? (
<span className="action-desc" id={descId}>
{description}
</span>
) : null}
</button>
)
}

function CardTile({ card, selected, isHand, onClick }: CardTileProps) {
const tag = cardTag(card)
const rank = rankShort(card.rank)
Expand Down
Loading