Various UX improvements.

This commit is contained in:
Greyson Parrelli
2026-07-24 07:54:58 -04:00
parent dd395f4bbf
commit 962e3bb5ff
5 changed files with 223 additions and 13 deletions
+46 -6
View File
@@ -32,8 +32,8 @@ function useFitText(dep: unknown) {
// renderEffect bolds each effect's trigger and renders the colon separating it
// from the action as an arrow, so "Sell: add 1 extra Apple" shows "Sell →" in
// bold. Cards can list several effects joined by " · "; each gets its own
// bolded trigger.
// bold. Cards can list several effects joined by " · "; each is stacked on its
// own line rather than run together.
function renderEffect(text: string) {
return text.split(' · ').map((segment, i) => {
const colon = segment.indexOf(':')
@@ -47,14 +47,41 @@ function renderEffect(text: string) {
</>
)
return (
<span key={i}>
{i > 0 && ' · '}
<span className="card-effect-line" key={i}>
{node}
</span>
)
})
}
// TRIGGER_GLOSS explains when each ability fires, shown under the hover
// magnifier so the trigger words aren't jargon.
const TRIGGER_GLOSS: Record<string, string> = {
Faint: 'when this pet is knocked out',
Play: 'when it enters the battle',
Hurt: 'when it survives taking damage',
Sell: 'when you sell it in the shop',
Buy: 'when you buy it',
Triple: 'when traded in as one of three',
'After Attacking': 'right after it attacks in a clash',
'Battle Prep': 'as the battle is arranged',
'Shop start': 'when the shop opens each round',
'Enemy Faints': 'when an enemy pet is knocked out',
'Enemy Played': 'when the enemy plays a pet',
}
// triggersOf extracts the trigger words (the part before each ":") from an
// effect string.
function triggersOf(text?: string): string[] {
if (!text) return []
const out: string[] = []
for (const seg of text.split(' · ')) {
const colon = seg.indexOf(':')
if (colon > 0) out.push(seg.slice(0, colon).trim())
}
return out
}
interface Props {
card: Card
size?: 'sm' | 'md' | 'lg'
@@ -113,11 +140,24 @@ export function CardView({
<div
className="card-magnify"
style={{
left: Math.min(hover.x + 24, window.innerWidth - 180),
top: Math.min(Math.max(hover.y - 120, 8), window.innerHeight - 250),
left: Math.min(hover.x + 24, window.innerWidth - 200),
top: Math.min(Math.max(hover.y - 120, 8), window.innerHeight - 320),
}}
>
<CardView card={card} size="lg" bonus={bonus} damage={damage} dead={dead} preview />
{(() => {
const trigs = triggersOf(card.effectText).filter((t) => TRIGGER_GLOSS[t])
if (trigs.length === 0) return null
return (
<div className="trigger-gloss">
{trigs.map((t) => (
<div className="trigger-gloss-line" key={t}>
<strong>{t}</strong> {TRIGGER_GLOSS[t]}
</div>
))}
</div>
)
})()}
</div>,
document.body,
)
+59 -1
View File
@@ -30,6 +30,35 @@ function prefersReducedMotion(): boolean {
)
}
// playTurnChime sounds a short two-note flourish when it becomes your turn,
// synthesized so there's no audio asset to ship. Silently no-ops if the Web
// Audio API is unavailable or blocked.
function playTurnChime() {
try {
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
if (!AC) return
const ctx = new AC()
const now = ctx.currentTime
;[659.25, 987.77].forEach((freq, i) => {
const t0 = now + i * 0.11
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'triangle'
osc.frequency.value = freq
gain.gain.setValueAtTime(0.0001, t0)
gain.gain.exponentialRampToValueAtTime(0.22, t0 + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.28)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(t0)
osc.stop(t0 + 0.3)
})
window.setTimeout(() => ctx.close(), 900)
} catch {
/* audio unavailable — ignore */
}
}
interface Props {
view: GameView
you: PlayerView
@@ -46,6 +75,23 @@ export function ShopPhase({ view, you, send }: Props) {
const avocados = you.avocados ?? 0
const freeBuy = !!you.firstBuyFree // Manta Ray: next buy costs no gold
const canBuy = myTurn && (you.coins > 0 || avocados > 0 || freeBuy)
// Nudge the player to pass once there's nothing left to buy.
const passHint = myTurn && you.petCount <= view.maxPets && you.coins <= 0 && avocados === 0 && !freeBuy
// Flash a "Your turn" banner (and a chime) when the turn swings to you — it's
// easy to miss in a busy shop.
const [turnBanner, setTurnBanner] = useState(false)
const wasMyTurn = useRef(myTurn)
useEffect(() => {
if (myTurn && !wasMyTurn.current && !view.pending && !view.pendingReveal) {
setTurnBanner(true)
if (!prefersReducedMotion()) playTurnChime()
const t = window.setTimeout(() => setTurnBanner(false), 1400)
wasMyTurn.current = myTurn
return () => window.clearTimeout(t)
}
wasMyTurn.current = myTurn
}, [myTurn, view.pending, view.pendingReveal])
const overPets = you.petCount > view.maxPets
const deck = you.deck ?? []
const opponent = view.players.find((p) => p.seat !== view.youSeat)
@@ -189,6 +235,18 @@ export function ShopPhase({ view, you, send }: Props) {
)}
</div>
{turnBanner && <div className="turn-banner">Your turn!</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>}
</div>
{/* Shop row */}
<section className="shop-row-wrap">
<div className="section-label">
@@ -326,7 +384,7 @@ export function ShopPhase({ view, you, send }: Props) {
Tier {Math.min(view.round + 1, view.maxRounds)}
</button>
<button
className="btn btn-ghost"
className={`btn btn-ghost ${passHint ? 'btn-pass-hint' : ''}`}
disabled={!myTurn || overPets}
onClick={() => setConfirmPass(true)}
title={
+3 -1
View File
@@ -118,7 +118,9 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)}
<span className="topbar-name">{p.name}</span>
<span className="chip">🏆 {p.trophies}</span>
{view.phase === 'shop' && (
{/* Your own gold shows as big discs above the buy row (ShopPhase);
the opponent's stays as a compact chip here. */}
{view.phase === 'shop' && p.seat !== view.youSeat && (
<span className="chip">🪙 {p.coins}</span>
)}
{(p.avocados ?? 0) > 0 && (