diff --git a/internal/game/battle.go b/internal/game/battle.go index e322c59..964d2f2 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -736,7 +736,9 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) { // not "attacks with" the pet, so no knockout applies. Reports a kill. throwRocks := func(from, target, dice int, source *Card) (killed bool) { 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 } roll := 0 diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index 8cfafd7..d1e7c6a 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -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 ( - - {i > 0 && ' · '} + {node} ) }) } +// TRIGGER_GLOSS explains when each ability fires, shown under the hover +// magnifier so the trigger words aren't jargon. +const TRIGGER_GLOSS: Record = { + 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({
+ {(() => { + const trigs = triggersOf(card.effectText).filter((t) => TRIGGER_GLOSS[t]) + if (trigs.length === 0) return null + return ( +
+ {trigs.map((t) => ( +
+ {t} — {TRIGGER_GLOSS[t]} +
+ ))} +
+ ) + })()}
, document.body, ) diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 43a3a79..59bb98c 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -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) { )} + {turnBanner &&
Your turn!
} + + {/* Coins as big golden discs above the buy row. */} +
+ {Array.from({ length: Math.max(you.coins, 0) }, (_, i) => ( + + 🪙 + + ))} + {you.coins === 0 && out of gold} +
+ {/* Shop row */}
@@ -326,7 +384,7 @@ export function ShopPhase({ view, you, send }: Props) { ↑ Tier {Math.min(view.round + 1, view.maxRounds)}