Various UX improvements.
This commit is contained in:
@@ -736,7 +736,9 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
|
|||||||
// not "attacks with" the pet, so no knockout applies. Reports a kill.
|
// not "attacks with" the pet, so no knockout applies. Reports a kill.
|
||||||
throwRocks := func(from, target, dice int, source *Card) (killed bool) {
|
throwRocks := func(from, target, dice int, source *Card) (killed bool) {
|
||||||
tu := sides[target].unit
|
tu := sides[target].unit
|
||||||
if tu == nil {
|
if tu == nil || dice <= 0 {
|
||||||
|
// No target, or a Per-scaled volley that came out to zero (Royal
|
||||||
|
// Flycatcher / Grizzly with no fainted pets): nothing to animate.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
roll := 0
|
roll := 0
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ function useFitText(dep: unknown) {
|
|||||||
|
|
||||||
// renderEffect bolds each effect's trigger and renders the colon separating it
|
// 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
|
// 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
|
// bold. Cards can list several effects joined by " · "; each is stacked on its
|
||||||
// bolded trigger.
|
// own line rather than run together.
|
||||||
function renderEffect(text: string) {
|
function renderEffect(text: string) {
|
||||||
return text.split(' · ').map((segment, i) => {
|
return text.split(' · ').map((segment, i) => {
|
||||||
const colon = segment.indexOf(':')
|
const colon = segment.indexOf(':')
|
||||||
@@ -47,14 +47,41 @@ function renderEffect(text: string) {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<span key={i}>
|
<span className="card-effect-line" key={i}>
|
||||||
{i > 0 && ' · '}
|
|
||||||
{node}
|
{node}
|
||||||
</span>
|
</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 {
|
interface Props {
|
||||||
card: Card
|
card: Card
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
@@ -113,11 +140,24 @@ export function CardView({
|
|||||||
<div
|
<div
|
||||||
className="card-magnify"
|
className="card-magnify"
|
||||||
style={{
|
style={{
|
||||||
left: Math.min(hover.x + 24, window.innerWidth - 180),
|
left: Math.min(hover.x + 24, window.innerWidth - 200),
|
||||||
top: Math.min(Math.max(hover.y - 120, 8), window.innerHeight - 250),
|
top: Math.min(Math.max(hover.y - 120, 8), window.innerHeight - 320),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardView card={card} size="lg" bonus={bonus} damage={damage} dead={dead} preview />
|
<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>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
interface Props {
|
||||||
view: GameView
|
view: GameView
|
||||||
you: PlayerView
|
you: PlayerView
|
||||||
@@ -46,6 +75,23 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
const avocados = you.avocados ?? 0
|
const avocados = you.avocados ?? 0
|
||||||
const freeBuy = !!you.firstBuyFree // Manta Ray: next buy costs no gold
|
const freeBuy = !!you.firstBuyFree // Manta Ray: next buy costs no gold
|
||||||
const canBuy = myTurn && (you.coins > 0 || avocados > 0 || freeBuy)
|
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 overPets = you.petCount > view.maxPets
|
||||||
const deck = you.deck ?? []
|
const deck = you.deck ?? []
|
||||||
const opponent = view.players.find((p) => p.seat !== view.youSeat)
|
const opponent = view.players.find((p) => p.seat !== view.youSeat)
|
||||||
@@ -189,6 +235,18 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Shop row */}
|
||||||
<section className="shop-row-wrap">
|
<section className="shop-row-wrap">
|
||||||
<div className="section-label">
|
<div className="section-label">
|
||||||
@@ -326,7 +384,7 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
↑ Tier {Math.min(view.round + 1, view.maxRounds)}
|
↑ Tier {Math.min(view.round + 1, view.maxRounds)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className={`btn btn-ghost ${passHint ? 'btn-pass-hint' : ''}`}
|
||||||
disabled={!myTurn || overPets}
|
disabled={!myTurn || overPets}
|
||||||
onClick={() => setConfirmPass(true)}
|
onClick={() => setConfirmPass(true)}
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
|||||||
)}
|
)}
|
||||||
<span className="topbar-name">{p.name}</span>
|
<span className="topbar-name">{p.name}</span>
|
||||||
<span className="chip">🏆 {p.trophies}</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>
|
<span className="chip">🪙 {p.coins}</span>
|
||||||
)}
|
)}
|
||||||
{(p.avocados ?? 0) > 0 && (
|
{(p.avocados ?? 0) > 0 && (
|
||||||
|
|||||||
+112
-4
@@ -1021,6 +1021,10 @@ h3 {
|
|||||||
clipped just under the name. */
|
clipped just under the name. */
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
/* Stack multiple abilities (e.g. Squirrel's Buy and Sell) vertically rather
|
||||||
|
than side by side. */
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
/* Center short text in the band, but fall back to top-aligned when the text
|
/* Center short text in the band, but fall back to top-aligned when the text
|
||||||
is taller than the band, so the leading words are never the ones clipped. */
|
is taller than the band, so the leading words are never the ones clipped. */
|
||||||
align-items: safe center;
|
align-items: safe center;
|
||||||
@@ -1028,6 +1032,11 @@ h3 {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Each ability on its own line within the effect band. */
|
||||||
|
.card-effect-line {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.card-lg .card-effect {
|
.card-lg .card-effect {
|
||||||
font-size: 0.63rem;
|
font-size: 0.63rem;
|
||||||
}
|
}
|
||||||
@@ -1224,6 +1233,104 @@ h3 {
|
|||||||
inset 0 0 0 1px rgba(246, 201, 78, 0.06);
|
inset 0 0 0 1px rgba(246, 201, 78, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Coins shown as big golden discs above the buy row. */
|
||||||
|
.shop-coins {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 40px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coin-disc {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.4));
|
||||||
|
animation: coin-pop 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes coin-pop {
|
||||||
|
from {
|
||||||
|
transform: scale(0.4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.coin-empty {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Your turn" flourish across the shop. */
|
||||||
|
.turn-banner {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 50;
|
||||||
|
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);
|
||||||
|
animation: turn-flash 1.4s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes turn-flash {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.6);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pass button nudge when there's nothing left to buy. */
|
||||||
|
.btn-pass-hint {
|
||||||
|
border-color: var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
box-shadow: 0 0 0 1px var(--gold), 0 0 14px rgba(246, 201, 78, 0.5);
|
||||||
|
animation: pass-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pass-pulse {
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 1px var(--gold), 0 0 22px rgba(246, 201, 78, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trigger explainer under the hover magnifier. */
|
||||||
|
.trigger-gloss {
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 200px;
|
||||||
|
background: rgba(0, 0, 0, 0.82);
|
||||||
|
border: 1px solid rgba(246, 201, 78, 0.35);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--parchment, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-gloss-line + .trigger-gloss-line {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-gloss strong {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
/* Mid-battle decision panel (Golden pack: Nurse Shark). */
|
/* Mid-battle decision panel (Golden pack: Nurse Shark). */
|
||||||
.battle-decision-options {
|
.battle-decision-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1517,9 +1624,9 @@ h3 {
|
|||||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
/* Roomy top/bottom padding: pets keep their central row while set-aside
|
/* Roomy top/bottom padding: pets keep their central row while set-aside
|
||||||
cards fan above and attached foods fan below. The bottom needs more room
|
cards fan above and attached foods fan below. The bottom needs plenty of
|
||||||
since a pet can carry several foods. */
|
room since a pet can carry a big stack of apples (Manatee, Fire Ant, …). */
|
||||||
padding: 132px 14px 208px;
|
padding: 132px 14px 268px;
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -1776,7 +1883,8 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.food-fan-card:not(:first-child) {
|
.food-fan-card:not(:first-child) {
|
||||||
margin-top: -88px;
|
/* Slightly tighter overlap so a tall apple stack fits the play area. */
|
||||||
|
margin-top: -92px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Released cards (a spent set-aside pet, or a used-up food perk) shrink and
|
/* Released cards (a spent set-aside pet, or a used-up food perk) shrink and
|
||||||
|
|||||||
Reference in New Issue
Block a user