Initial pass at Golden Pack.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,6 +11,18 @@ const PET_EMOJI: Record<string, string> = {
|
||||
Monkey: '🐒', Rhino: '🦏', Crocodile: '🐊', Scorpion: '🦂', Seal: '🦭', Shark: '🦈', Turkey: '🦃',
|
||||
// Tier 6
|
||||
Gorilla: '🦍', Fly: '🪰', Leopard: '🐆', Mammoth: '🦣', Cat: '🐱', Snake: '🐍', Wolverine: '🐺',
|
||||
// Golden pack — Tier 1
|
||||
Groundhog: '🦫', 'Pied Tamarin': '🐒', Chipmunk: '🐿️', 'Cone Snail': '🐚', Bulldog: '🐕', Opossum: '🐀',
|
||||
// Golden pack — Tier 2
|
||||
'Black-Necked Stilt': '🦤', Lizard: '🦎', 'Hercules Beetle': '🪲', Stoat: '🦡', 'Desert Rain Frog': '🐸', 'Honduran White Bat': '🦇',
|
||||
// Golden pack — Tier 3
|
||||
'Guinea Fowl': '🐔', 'Surgeon Fish': '🐠', Osprey: '🦅', Anteater: '🐜', Bear: '🐻', 'Royal Flycatcher': '🐦', Flea: '🦟',
|
||||
// Golden pack — Tier 4
|
||||
'Saiga Antelope': '🦌', Vaquita: '🐬', 'Poison Dart Frog': '🐸', 'Manta Ray': '🐟', Slug: '🐌', Cockatoo: '🦜', Manatee: '🦭',
|
||||
// Golden pack — Tier 5
|
||||
Nyala: '🦌', 'Nurse Shark': '🦈', 'Giant Isopod': '🦞', 'Blue-Ringed Octopus': '🐙', Raccoon: '🦝', 'Fire Ant': '🐜', Macaque: '🐒',
|
||||
// Golden pack — Tier 6
|
||||
'Highland Cow': '🐄', Wildebeest: '🐃', 'Grizzly Bear': '🐻', Catfish: '🐟', Komodo: '🦎', 'Bird of Paradise': '🦚', 'German Shepherd': '🐕🦺',
|
||||
// Summons & foods
|
||||
Bee: '🐝',
|
||||
Apple: '🍎',
|
||||
@@ -19,6 +31,13 @@ const PET_EMOJI: Record<string, string> = {
|
||||
Pineapple: '🍍',
|
||||
Chili: '🌶️',
|
||||
Melon: '🍈',
|
||||
// Golden pack tokens & perk foods
|
||||
'Golden Retriever': '🦮',
|
||||
Trumpet: '🎺',
|
||||
Avocado: '🥑',
|
||||
Potato: '🥔',
|
||||
Durian: '🥭',
|
||||
Tomato: '🍅',
|
||||
}
|
||||
|
||||
export function artFor(name: string): string {
|
||||
|
||||
@@ -1224,6 +1224,56 @@ h3 {
|
||||
inset 0 0 0 1px rgba(246, 201, 78, 0.06);
|
||||
}
|
||||
|
||||
/* Mid-battle decision panel (Golden pack: Nurse Shark). */
|
||||
.battle-decision-options {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.battle-decision-options .btn {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
/* Set-aside Avocado tokens (Golden pack): a slim tray with a toggle pill. */
|
||||
.avocado-zone {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
border-radius: 18px;
|
||||
padding: 12px 18px 14px;
|
||||
box-shadow:
|
||||
var(--tray-inset),
|
||||
inset 0 0 0 1px rgba(246, 201, 78, 0.06);
|
||||
}
|
||||
|
||||
.avocado-token {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(246, 201, 78, 0.3);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: var(--gold);
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.avocado-token:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.avocado-token.is-active {
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 10px rgba(246, 201, 78, 0.45);
|
||||
}
|
||||
|
||||
.avocado-token:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.shop-row,
|
||||
.deck-row {
|
||||
display: flex;
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface PlayerView {
|
||||
isBot?: boolean
|
||||
deckSize: number
|
||||
petCount: number
|
||||
avocados?: number // set-aside Avocado tokens (Golden pack)
|
||||
firstBuyFree?: boolean // Manta Ray: next buy is free (self only)
|
||||
buysThisRound?: number // Blue-Ringed Octopus counter (self only)
|
||||
deck?: Card[]
|
||||
}
|
||||
|
||||
@@ -38,6 +41,23 @@ export interface PendingTrade {
|
||||
options: [Card, Card]
|
||||
}
|
||||
|
||||
// Cockatoo (Golden pack): the buyer must reveal one of their pets.
|
||||
export interface PendingReveal {
|
||||
playerId: string
|
||||
source: string
|
||||
options?: string[] // eligible pet card ids (buyer only)
|
||||
}
|
||||
|
||||
// Nurse Shark (Golden pack): a mid-battle Trumpet-spend choice.
|
||||
export interface PendingBattleDecision {
|
||||
seat: number
|
||||
kind: string
|
||||
petName: string
|
||||
min: number
|
||||
max: number
|
||||
trumpets: number
|
||||
}
|
||||
|
||||
export interface BattleEvent {
|
||||
type:
|
||||
| 'prep'
|
||||
@@ -53,6 +73,8 @@ export interface BattleEvent {
|
||||
| 'heal'
|
||||
| 'setaside'
|
||||
| 'release'
|
||||
| 'trumpet' // a side gains (+) or spends/loses (-) Trumpets (Golden pack)
|
||||
| 'prevent' // a Cone Snail shaves damage off a hit (Golden pack)
|
||||
seat?: number
|
||||
target?: number
|
||||
card?: Card
|
||||
@@ -117,6 +139,8 @@ export interface GameView {
|
||||
deckCounts: number[]
|
||||
players: PlayerView[]
|
||||
pending?: PendingTrade
|
||||
pendingReveal?: PendingReveal
|
||||
pendingBattle?: PendingBattleDecision
|
||||
battle?: BattleResult
|
||||
winnerSeat: number
|
||||
log?: LogEntry[]
|
||||
@@ -129,9 +153,12 @@ export type ClientMessage =
|
||||
| { type: 'removePlayer'; target: string }
|
||||
| { type: 'start' }
|
||||
| { type: 'buy'; row: number }
|
||||
| { type: 'buyAvocado'; row: number }
|
||||
| { type: 'sell'; cards: string[] }
|
||||
| { type: 'trade'; cards: string[] }
|
||||
| { type: 'tradeChoose'; pick: number }
|
||||
| { type: 'revealChoose'; card: string }
|
||||
| { type: 'battleChoose'; value: number }
|
||||
| { type: 'pass' }
|
||||
| { type: 'arrange'; order: string[] }
|
||||
| { type: 'ready' }
|
||||
|
||||
Reference in New Issue
Block a user