Fix a bunch of bugs.

This commit is contained in:
Greyson Parrelli
2026-07-24 09:51:25 -04:00
parent ceab30dc2e
commit 094f38593e
8 changed files with 308 additions and 79 deletions
+16 -21
View File
@@ -206,6 +206,15 @@ export function CardView({
onMouseLeave={preview ? undefined : () => setHover(null)}
>
{previewEl}
{/* Power sits in a spiky burst badge in the top-left corner (pets only),
with any battle damage marker beside it. It's absolutely placed so it
doesn't eat into the room the name and ability text need. */}
{card.kind === 'pet' && (
<div className="card-combat">
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
{damage > 0 && !dead && <span className="card-damage">{damage}</span>}
</div>
)}
<div className="card-top">
<span className="card-tier">{card.tier ? `T${card.tier}` : '·'}</span>
{card.suit && <span className={`suit-dot suit-${card.suit}`} title={`${card.suit} suit`} />}
@@ -214,27 +223,13 @@ export function CardView({
{artFor(card.name)}
</div>
<div className="card-name">{card.name}</div>
{card.kind === 'pet' ? (
<>
{card.effectText && (
<div className="card-effect" ref={effectRef}>
{renderEffect(card.effectText)}
</div>
)}
<div className="card-bottom">
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
{damage > 0 && !dead && <span className="card-damage">{damage}</span>}
</div>
</>
) : (
<div className="card-effect" ref={effectRef}>
{card.effectText
? renderEffect(card.effectText)
: card.food === 'apple'
? '+1 power (this battle)'
: ''}
</div>
)}
<div className="card-effect" ref={effectRef}>
{card.effectText
? renderEffect(card.effectText)
: card.kind === 'food' && card.food === 'apple'
? '+1 power (this battle)'
: ''}
</div>
{selected && <div className="card-check"></div>}
</div>
)
+28 -9
View File
@@ -93,6 +93,17 @@ export function ShopPhase({ view, you, send }: Props) {
wasMyTurn.current = myTurn
}, [myTurn, view.pending, view.pendingReveal])
const overPets = you.petCount > view.maxPets
// Show every coin you started the round with, fading the spent ones rather
// than dropping them. Coins only fall within a round, so the highest count
// seen this round is the starting purse. Reset when the round changes.
const purse = useRef({ round: view.round, max: you.coins })
if (purse.current.round !== view.round) {
purse.current = { round: view.round, max: you.coins }
}
purse.current.max = Math.max(purse.current.max, you.coins)
const totalCoins = purse.current.max
const deck = you.deck ?? []
const opponent = view.players.find((p) => p.seat !== view.youSeat)
const pending = view.pending
@@ -235,16 +246,24 @@ export function ShopPhase({ view, you, send }: Props) {
)}
</div>
{turnBanner && <div className="turn-banner">Your turn!</div>}
{turnBanner && (
<div className="turn-banner">
<span className="turn-banner-pill">Your turn!</span>
</div>
)}
{/* Coins as big golden discs above the buy row. */}
<div className="shop-coins" aria-label={`${you.coins} gold`}>
{Array.from({ length: Math.max(you.coins, 0) }, (_, i) => (
<span key={i} className="coin-disc">
🪙
</span>
))}
{you.coins === 0 && <span className="coin-empty muted">out of gold</span>}
{/* Coins as big golden discs above the buy row. Spent coins stay put but
grey out, so you can see what you started the round with. */}
<div className="shop-coins" aria-label={`${you.coins} of ${totalCoins} gold`}>
{totalCoins > 0 ? (
Array.from({ length: totalCoins }, (_, i) => (
<span key={i} className={`coin-disc ${i >= you.coins ? 'is-spent' : ''}`}>
🪙
</span>
))
) : (
<span className="coin-empty muted">out of gold</span>
)}
</div>
{/* Shop row */}
+50
View File
@@ -1,7 +1,9 @@
import { useMemo, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom'
import { useGame } from '../useGame'
import { useCatalog } from '../useCatalog'
import { CardView } from './CardView'
import type { Card, Session } from '../types'
import { Lobby } from './Lobby'
import { ShopPhase } from './ShopPhase'
@@ -33,6 +35,10 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
return { round: battleRound, step: next }
})
// A shop-phase peek at an opponent's deck from the previous round's battle
// (its arranged lineup is already public). Anchored under the clicked button.
const [deckPeek, setDeckPeek] = useState<{ seat: number; rect: DOMRect } | null>(null)
// The battle outcome is known before the replay plays out, so we hold its
// "result" log entry back: it's dropped from the persistent list during the
// battle and appended as the final battle line only once the replay reaches
@@ -145,6 +151,21 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.phase === 'shop' && p.seat !== view.youSeat && (
<span className="chip">🪙 {p.coins}</span>
)}
{/* Peek at the opponent's deck from last round's battle. */}
{view.phase === 'shop' &&
p.seat !== view.youSeat &&
(view.battle?.lineups?.[p.seat]?.length ?? 0) > 0 && (
<button
className={`chip chip-btn ${deckPeek?.seat === p.seat ? 'is-active' : ''}`}
title="See their deck from last round's battle"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
setDeckPeek((cur) => (cur?.seat === p.seat ? null : { seat: p.seat, rect }))
}}
>
👁 deck
</button>
)}
{(p.avocados ?? 0) > 0 && (
<span className="chip" title="Set-aside Avocados">🥑 {p.avocados}</span>
)}
@@ -186,6 +207,35 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.debug && (
<DebugPanel canGrant={view.phase === 'shop'} pack={view.pack} send={send} />
)}
{deckPeek &&
view.phase === 'shop' &&
(() => {
const lineup = view.battle?.lineups?.[deckPeek.seat] ?? []
if (!lineup.length) return null
const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent'
const left = Math.max(8, Math.min(deckPeek.rect.left, window.innerWidth - 380))
return createPortal(
<>
<div className="deck-peek-backdrop" onClick={() => setDeckPeek(null)} />
<div
className="deck-peek deck-peek-toolbar"
style={{ top: deckPeek.rect.bottom + 8, left }}
>
<div className="deck-peek-label">
{oppName}s deck last round · {lineup.length} card
{lineup.length !== 1 ? 's' : ''} (top first)
</div>
<div className="deck-peek-cards">
{lineup.map((c, i) => (
<CardView key={c.id || i} card={c} size="sm" />
))}
</div>
</div>
</>,
document.body,
)
})()}
</div>
)
}
+110 -36
View File
@@ -468,6 +468,28 @@ h3 {
font-weight: 700;
}
/* A chip that's actually a button (e.g. peek at the opponent's deck). */
.chip-btn {
font-family: var(--font-body);
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
color: inherit;
border-radius: 999px;
padding: 1px 8px;
transition: background 120ms ease, border-color 120ms ease;
}
.chip-btn:hover {
background: rgba(255, 255, 255, 0.18);
}
.chip-btn.is-active {
background: var(--gold);
color: var(--cocoa);
border-color: var(--gold);
}
.conn-dot {
width: 8px;
height: 8px;
@@ -961,10 +983,26 @@ h3 {
.card-top {
width: 100%;
display: flex;
justify-content: space-between;
/* The power badge now floats over the top-left corner, so tier + suit sit
together at the top-right. */
justify-content: flex-end;
align-items: center;
gap: 5px;
font-size: 0.72rem;
z-index: 1;
min-height: 14px;
}
/* Combat stats (power badge + battle damage marker) pinned to the top-left
corner. Absolute so they don't push the name and ability text down. */
.card-combat {
position: absolute;
top: 5px;
left: 5px;
z-index: 2;
display: flex;
align-items: center;
gap: 4px;
}
.card-tier {
@@ -1017,6 +1055,11 @@ h3 {
flex: 0 0 auto;
z-index: 1;
color: var(--cocoa);
/* Always center the name — including when a long name wraps to two lines, so
the second line stays centered instead of drifting off to one side. */
width: 100%;
text-align: center;
overflow-wrap: break-word;
}
.card-lg .card-name {
@@ -1060,14 +1103,6 @@ h3 {
display: none;
}
.card-bottom {
display: flex;
gap: 6px;
align-items: center;
margin-top: auto;
z-index: 1;
}
/* Suit markers as glossy enamel dots. */
.suit-dot {
display: inline-block;
@@ -1094,36 +1129,43 @@ h3 {
background: radial-gradient(circle at 35% 28%, #ffdd6b, #e6ac1f);
}
/* The power badge is a stamped honey coin with a raised rim. */
/* The power badge is a fiery starburst — like a little explosion — carrying the
pet's attack. The spikes are cut with clip-path, so the drop shadow lives on
`filter` (a box-shadow would be clipped away with the corners). */
.card-power {
font-family: var(--font-display);
background: radial-gradient(circle at 35% 28%, #ffcf6b, var(--coral-dark));
background: radial-gradient(circle at 38% 30%, #ffcf6b 4%, var(--coral) 42%, var(--coral-dark));
color: #fff;
text-shadow: 0 1px 2px rgba(120, 45, 5, 0.55);
border-radius: 50%;
width: 31px;
height: 31px;
text-shadow: 0 1px 2px rgba(120, 45, 5, 0.7);
width: 32px;
height: 32px;
display: grid;
place-items: center;
font-size: 0.98rem;
/* The rim is drawn with an inset ring rather than a real border: a
border-radius:50% element with a semi-transparent border renders a
visible seam at its 6 o'clock point in Chromium (scale-invariant). */
box-shadow:
inset 0 0 0 2px rgba(120, 45, 5, 0.35),
inset 0 1.5px 1px rgba(255, 255, 255, 0.55),
inset 0 -2px 3px rgba(120, 45, 5, 0.4),
0 2px 3px rgba(0, 0, 0, 0.3);
font-size: 0.9rem;
clip-path: polygon(
50% 0%, 61% 18%, 82% 12%, 78% 34%, 100% 43%, 83% 58%,
93% 79%, 70% 76%, 62% 98%, 50% 82%, 38% 98%, 30% 76%,
7% 79%, 17% 58%, 0% 43%, 22% 34%, 18% 12%, 39% 18%
);
filter: drop-shadow(0 2px 2px rgba(20, 12, 4, 0.5));
}
.card-power.is-buffed {
background: radial-gradient(circle at 35% 28%, #9ff0af, #1f9e55);
text-shadow: 0 1px 2px rgba(15, 60, 30, 0.55);
box-shadow:
inset 0 0 0 2px rgba(15, 80, 40, 0.4),
inset 0 1.5px 1px rgba(255, 255, 255, 0.55),
inset 0 -2px 3px rgba(15, 80, 40, 0.4),
0 0 8px rgba(80, 220, 120, 0.5);
background: radial-gradient(circle at 38% 30%, #c9ffd4 4%, #4fd07f 42%, #1f9e55);
text-shadow: 0 1px 2px rgba(15, 60, 30, 0.7);
filter: drop-shadow(0 0 5px rgba(80, 220, 120, 0.75));
}
.card-lg .card-power {
width: 37px;
height: 37px;
font-size: 1.02rem;
}
.card-sm .card-power {
width: 27px;
height: 27px;
font-size: 0.76rem;
}
.card-damage {
@@ -1263,6 +1305,15 @@ h3 {
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.4));
animation: coin-pop 0.25s ease;
transition: filter 200ms ease, opacity 200ms ease, transform 200ms ease;
}
/* A spent coin stays in place but greys out and fades. */
.coin-disc.is-spent {
filter: grayscale(1) brightness(0.7);
opacity: 0.4;
transform: scale(0.88);
animation: none;
}
@keyframes coin-pop {
@@ -1285,10 +1336,23 @@ h3 {
place-items: center;
pointer-events: none;
z-index: 50;
}
/* Light text on a golden rounded-rect plate, so the flourish reads as a solid
badge rather than bare text floating over the board. */
.turn-banner-pill {
font-family: var(--font-display);
font-size: clamp(2.5rem, 9vw, 6rem);
color: var(--gold);
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
font-size: clamp(2rem, 7vw, 4.5rem);
color: #fff8e6;
background: linear-gradient(180deg, var(--gold), var(--gold-deep));
padding: 0.28em 0.8em;
border-radius: 22px;
border: 3px solid rgba(255, 255, 255, 0.55);
text-shadow: 0 2px 5px rgba(120, 78, 8, 0.55);
box-shadow:
inset 0 2px 0 rgba(255, 255, 255, 0.55),
inset 0 -6px 14px rgba(150, 100, 20, 0.35),
0 14px 34px rgba(0, 0, 0, 0.5);
animation: turn-flash 1.4s ease forwards;
}
@@ -1978,7 +2042,9 @@ h3 {
.battle-unit .card {
width: 96px;
height: 132px;
/* Taller than a shop card so the full ability text fits without shrinking to
an unreadable size during the battle. */
height: 162px;
}
/* Battle cards are short, so give the ability text more of the card by
@@ -2389,7 +2455,7 @@ h3 {
}
.battle-unit .card {
width: 78px;
height: 110px;
height: 134px;
}
}
@@ -2540,3 +2606,11 @@ h3 {
gap: 6px;
max-width: min(60vw, 560px);
}
/* Backdrop behind the toolbar deck-peek: any click outside dismisses it. Sits
below the popover (z 60) but above the board. */
.deck-peek-backdrop {
position: fixed;
inset: 0;
z-index: 55;
}