Initial pass at Golden Pack.

This commit is contained in:
Greyson Parrelli
2026-07-24 07:47:05 -04:00
parent e74f983470
commit dd395f4bbf
27 changed files with 2770 additions and 150 deletions
+117 -26
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import type { BattleEvent, Card, ClientMessage, GameView, PendingBattleDecision } from '../types'
import { CardView } from './CardView'
import { DiceRoll, ROLL_MS } from './DiceRoll'
@@ -52,6 +52,8 @@ const EVENT_MS: Record<BattleEvent['type'], number> = {
heal: 900,
setaside: 700,
release: 500,
trumpet: 800,
prevent: 900,
}
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
@@ -86,6 +88,25 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
break
case 'reveal': {
const s = sides[ev.seat!]
// The Golden Retriever is summoned straight into play, not flipped off
// the deck, so it doesn't decrement the stack.
if (ev.card?.name === 'Golden Retriever') {
const trumpets = ev.count ?? 0
s.unit = {
card: ev.card!,
// Show the Trumpets that powered it, fanned like food tokens.
foods: Array.from({ length: trumpets }, (_, i) => ({
id: `${ev.card!.id}-t${i}`,
kind: 'food' as const,
name: 'Trumpet',
food: 'trumpet',
})),
bonus: 0,
damage: 0,
dying: false,
}
break
}
s.stack--
const card = ev.card!
if (card.kind === 'food') {
@@ -164,6 +185,14 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
}
case 'shield':
break // pure animation; no state change
case 'trumpet':
break // pure animation; the pool isn't drawn on the board
case 'prevent': {
// Cone Snail shaved damage off the hit; the reduced total rides along.
const u = sides[ev.seat!].unit
if (u) u.damage = ev.damageAfter ?? u.damage
break
}
case 'setaside':
if (ev.card) sides[ev.seat!].setAside.push(ev.card)
break
@@ -194,6 +223,11 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st
}
case 'shield':
return ev.seat === seat ? '🛡️' : null
case 'prevent':
return ev.seat === seat ? `🛡️ ${ev.count}` : null
case 'trumpet':
if (ev.seat !== seat) return null
return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}`
case 'eat':
return ev.seat === seat ? '🍎' : null
case 'heal':
@@ -519,37 +553,94 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
)
})()}
{done && (
<div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
<div className="battle-result-title">
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
</div>
{!draw && (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins{' '}
{'🏆'.repeat(battle.trophies)}
{done && view.pendingBattle ? (
<BattleDecision
pd={view.pendingBattle}
youSeat={youSeat}
oppName={opp?.name ?? 'Opponent'}
send={send}
/>
) : (
done && (
<div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
<div className="battle-result-title">
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
</div>
)}
{draw && <div className="battle-result-sub">No trophies awarded</div>}
{acked ? (
<p className="muted">Waiting for opponent</p>
) : (
<button
className="btn btn-primary btn-big"
onClick={() => {
setAcked(true)
send({ type: 'ready' })
}}
>
{battle.round >= view.maxRounds ? 'See final results' : 'Next round →'}
</button>
)}
</div>
{!draw && (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins{' '}
{'🏆'.repeat(battle.trophies)}
</div>
)}
{draw && <div className="battle-result-sub">No trophies awarded</div>}
{acked ? (
<p className="muted">Waiting for opponent</p>
) : (
<button
className="btn btn-primary btn-big"
onClick={() => {
setAcked(true)
send({ type: 'ready' })
}}
>
{battle.round >= view.maxRounds ? 'See final results' : 'Next round →'}
</button>
)}
</div>
)
)}
</div>
)
}
// BattleDecision is the mid-battle prompt (Golden pack: Nurse Shark). The
// deciding player picks how many Trumpets to spend; the other player waits.
function BattleDecision({
pd,
youSeat,
oppName,
send,
}: {
pd: PendingBattleDecision
youSeat: number
oppName: string
send: (msg: ClientMessage) => void
}) {
const [sent, setSent] = useState(false)
// Reset when a fresh decision arrives (e.g. a second Nurse Shark).
useEffect(() => setSent(false), [pd.seat, pd.trumpets, pd.max])
if (pd.seat !== youSeat) {
return (
<div className="battle-result">
<p className="muted">{oppName} is deciding {pd.petName}</p>
</div>
)
}
return (
<div className="battle-result battle-decision">
<div className="battle-result-title">{pd.petName}</div>
<div className="battle-result-sub">
Spend Trumpets to throw 2 🪨 each you hold {pd.trumpets} 🎺
</div>
<div className="battle-decision-options">
{Array.from({ length: pd.max + 1 }, (_, n) => (
<button
key={n}
className="btn btn-primary"
disabled={sent}
onClick={() => {
setSent(true)
send({ type: 'battleChoose', value: n })
}}
>
{n === 0 ? 'Spend none' : `${n} 🎺 → ${2 * n} 🪨`}
</button>
))}
</div>
</div>
)
}
// clashDamageTaken computes how much damage a seat's pet took in the clash
// at event index `idx` (its damage total there minus its total beforehand).
function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number {
+2
View File
@@ -24,6 +24,8 @@ const BATTLE_ICONS: Record<BattleEvent['type'], string> = {
heal: '💚',
setaside: '🃏',
release: '↩️',
trumpet: '🎺',
prevent: '🛡️',
}
// battleLogLines turns the battle events revealed up to `step` into readable
+68 -5
View File
@@ -39,8 +39,13 @@ interface Props {
export function ShopPhase({ view, you, send }: Props) {
const [selected, setSelected] = useState<string[]>([])
const [confirmPass, setConfirmPass] = useState(false)
// When set, the next buy is paid by discarding an Avocado instead of a coin
// (Golden pack). It also kicks in automatically when out of coins.
const [useAvocado, setUseAvocado] = useState(false)
const myTurn = view.turn === view.youSeat && !you.ready
const canBuy = myTurn && you.coins > 0
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)
const overPets = you.petCount > view.maxPets
const deck = you.deck ?? []
const opponent = view.players.find((p) => p.seat !== view.youSeat)
@@ -151,7 +156,11 @@ export function ShopPhase({ view, you, send }: Props) {
// Safety net: if the card never shows up in the deck, stop hiding its slot.
window.setTimeout(() => setBuyFly((b) => (b?.id === card.id ? null : b)), 2000)
}
act({ type: 'buy', row })
// A free first buy (Manta Ray) always goes through the normal buy; otherwise
// pay with an Avocado when chosen, or when out of coins.
const payAvocado = !freeBuy && avocados > 0 && (useAvocado || you.coins <= 0)
act({ type: payAvocado ? 'buyAvocado' : 'buy', row })
setUseAvocado(false)
}
return (
@@ -212,13 +221,45 @@ export function ShopPhase({ view, you, send }: Props) {
</div>
{myTurn && (
<div className="hint">
{canBuy
? 'Tap a card to buy it for 1 🪙 — selling and trading are free'
: 'No coins left — you can still sell, trade, or pass'}
{freeBuy
? 'Your first buy this round is free 🎉 — tap a card'
: you.coins > 0
? 'Tap a card to buy it for 1 🪙 — selling and trading are free'
: avocados > 0
? 'No coins — tap a card to buy it by discarding an Avocado 🥑'
: 'No coins left — you can still sell, trade, or pass'}
</div>
)}
</section>
{/* Set-aside Avocados (Golden pack) */}
{avocados > 0 && (
<section className="avocado-zone">
<div className="section-label">
Set aside
<span className="muted"> · discard instead of paying 1 🪙</span>
</div>
<button
type="button"
className={`avocado-token ${useAvocado ? 'is-active' : ''}`}
disabled={!myTurn || you.coins <= 0}
onClick={() => setUseAvocado((v) => !v)}
title={
you.coins <= 0
? 'Out of coins — buys will spend an Avocado'
: useAvocado
? 'Your next buy will spend an Avocado (click to cancel)'
: 'Spend an Avocado on your next buy instead of a coin'
}
>
🥑 ×{avocados}
</button>
{myTurn && (useAvocado || you.coins <= 0) && (
<div className="hint">Your next buy will discard an Avocado no coin spent.</div>
)}
</section>
)}
{/* Your deck */}
<section className="deck-wrap">
<div className="section-label">
@@ -350,6 +391,28 @@ export function ShopPhase({ view, you, send }: Props) {
</div>
)}
{/* Cockatoo reveal picker (Golden pack) */}
{view.pendingReveal?.playerId === you.id && (
<div className="modal-backdrop">
<div className="modal">
<h3>Reveal a pet gain Apples equal to its Power</h3>
<div className="modal-cards">
{(view.pendingReveal.options ?? []).map((id) => {
const c = deck.find((d) => d.id === id)
return c ? (
<CardView
key={id}
card={c}
size="lg"
onClick={() => send({ type: 'revealChoose', card: id })}
/>
) : null
})}
</div>
</div>
</div>
)}
{cardFlyer &&
createPortal(
<div
+3
View File
@@ -121,6 +121,9 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.phase === 'shop' && (
<span className="chip">🪙 {p.coins}</span>
)}
{(p.avocados ?? 0) > 0 && (
<span className="chip" title="Set-aside Avocados">🥑 {p.avocados}</span>
)}
</div>
))}
</div>