Various UX improvements.
This commit is contained in:
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user