Initial pass at unicorn pack.

This commit is contained in:
Greyson Parrelli
2026-07-25 00:37:44 -04:00
parent f3783b44bf
commit a1d57fe1dd
21 changed files with 2419 additions and 44 deletions
+60 -2
View File
@@ -23,6 +23,8 @@ interface UnitVis {
bonus: number
damage: number
dying: boolean
spooked: number // Unicorn pack ailment: lowers the pet's clash attack
exposed: number // Unicorn pack ailment: raises damage it takes per hit
}
interface SideVis {
@@ -54,6 +56,9 @@ const EVENT_MS: Record<BattleEvent['type'], number> = {
release: 500,
trumpet: 800,
prevent: 900,
mana: 800,
ailment: 900,
bounce: 1000,
}
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
@@ -104,12 +109,16 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
bonus: 0,
damage: 0,
dying: false,
spooked: 0,
exposed: 0,
}
break
}
s.stack--
const card = ev.card!
if (card.kind === 'food') {
if (card.kind === 'food' || card.kind === 'ailment') {
// Ailments waiting on top of the deck sit in the pending fan until a
// pet arrives; the backend then emits an 'ailment' event to attach it.
s.pending.push(card)
} else {
s.unit = {
@@ -118,6 +127,8 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
bonus: ev.bonus ?? appleCount(s.pending),
damage: 0,
dying: false,
spooked: 0,
exposed: 0,
}
s.pending = []
}
@@ -187,6 +198,24 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
break // pure animation; no state change
case 'trumpet':
break // pure animation; the pool isn't drawn on the board
case 'mana':
break // pure animation; the Mana pool isn't drawn on the board
case 'ailment': {
// A pet gained (count > 0) an ailment; count 0 means Baku shrugged it.
const u = sides[ev.seat!].unit
const kind = ev.card?.ailment
if (u && ev.count && kind === 'spooked') u.spooked += ev.count
else if (u && ev.count && kind === 'exposed') u.exposed += ev.count
break
}
case 'bounce': {
// The target pet is sent to the bottom of its deck: it leaves play and
// the deck grows by one.
const t = sides[ev.target!]
t.unit = null
t.stack++
break
}
case 'prevent': {
// Cone Snail shaved damage off the hit; the reduced total rides along.
const u = sides[ev.seat!].unit
@@ -228,6 +257,14 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st
case 'trumpet':
if (ev.seat !== seat) return null
return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}`
case 'mana':
if (ev.seat !== seat) return null
return (ev.count ?? 0) >= 0 ? `🔮 +${ev.count}` : `🔮 ${ev.count}`
case 'ailment':
if (ev.seat !== seat) return null
return ev.card?.ailment === 'spooked' ? '👻' : '🎯'
case 'bounce':
return ev.target === seat ? '🌀' : null
case 'eat':
return ev.seat === seat ? '🍎' : null
case 'heal':
@@ -337,7 +374,8 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
!done &&
lastEvent?.seat === seat &&
(lastEvent.type === 'prep' ||
(lastEvent.type === 'reveal' && lastEvent.card?.kind === 'food'))
(lastEvent.type === 'reveal' &&
(lastEvent.card?.kind === 'food' || lastEvent.card?.kind === 'ailment')))
? lastEvent.card?.id
: null
// A pet just set aside slides into the set-aside row beside the arena.
@@ -450,6 +488,26 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
damage={s.unit.damage}
dead={s.unit.dying}
/>
{(s.unit.spooked > 0 || s.unit.exposed > 0) && (
<div className="ailment-badges">
{s.unit.spooked > 0 && (
<span
className="ailment-badge ailment-spooked"
title={`Spooked ×${s.unit.spooked}: deals ${s.unit.spooked} less damage`}
>
👻{s.unit.spooked > 1 ? s.unit.spooked : ''}
</span>
)}
{s.unit.exposed > 0 && (
<span
className="ailment-badge ailment-exposed"
title={`Exposed ×${s.unit.exposed}: takes ${s.unit.exposed} extra damage per hit`}
>
🎯{s.unit.exposed > 1 ? s.unit.exposed : ''}
</span>
)}
</div>
)}
{pop && (
<div key={`pop-${step}`} className={pop.startsWith('') ? 'damage-pop' : 'fx-pop'}>
{pop}
+6 -2
View File
@@ -180,7 +180,7 @@ export function CardView({
const classes = [
'card',
`card-${size}`,
card.kind === 'food' ? 'card-food' : 'card-pet',
card.kind === 'ailment' ? 'card-ailment' : card.kind === 'food' ? 'card-food' : 'card-pet',
selected ? 'is-selected' : '',
disabled ? 'is-disabled' : '',
dead ? 'is-dead' : '',
@@ -228,7 +228,11 @@ export function CardView({
? renderEffect(card.effectText)
: card.kind === 'food' && card.food === 'apple'
? '+1 power (this battle)'
: ''}
: card.ailment === 'spooked'
? 'Deals 1 less damage'
: card.ailment === 'exposed'
? 'Takes 1 extra damage'
: ''}
</div>
{selected && <div className="card-check"></div>}
</div>
+3
View File
@@ -28,6 +28,9 @@ const BATTLE_ICONS: Record<BattleEvent['type'], string> = {
release: '↩️',
trumpet: '🎺',
prevent: '🛡️',
mana: '🔮',
ailment: '👻',
bounce: '🌀',
}
// battleLogLines turns the battle events revealed up to `step` into readable
+49
View File
@@ -74,6 +74,11 @@ export function ShopPhase({ view, you, send }: Props) {
const myTurn = view.turn === view.youSeat && !you.ready
const avocados = you.avocados ?? 0
const trumpets = you.trumpets ?? 0
const mana = you.mana ?? 0
const shopPeek = you.shopPeek
const hasBigfoot = (you.deck ?? []).some((c) =>
(c.effects as { action?: string }[] | undefined)?.some((e) => e.action === 'peekShop'),
)
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.
@@ -382,6 +387,28 @@ export function ShopPhase({ view, you, send }: Props) {
🎺 ×{trumpets}
</div>
)}
{mana > 0 && (
<div className="trumpet-indicator" title="Mana — a persistent resource that powers Unicorn abilities">
🔮 ×{mana}
</div>
)}
{hasBigfoot && (
<div className="peek-indicator">
<button
className="btn btn-ghost btn-sm"
disabled={!myTurn || !!shopPeek}
onClick={() => send({ type: 'peek' })}
title="Bigfoot: look at the top of the shop deck (once per round)"
>
👁 Peek shop deck
</button>
{shopPeek && (
<div className="peek-card" title="Next card off the shop deck">
<CardView card={shopPeek} size="sm" />
</div>
)}
</div>
)}
</section>
{/* Actions */}
@@ -496,6 +523,28 @@ export function ShopPhase({ view, you, send }: Props) {
</div>
)}
{/* Water of Youth sacrifice picker (Unicorn pack) */}
{view.pendingSacrifice?.playerId === you.id && (
<div className="modal-backdrop">
<div className="modal">
<h3>Sacrifice a pet to draw a free tier {view.pendingSacrifice.tier} card</h3>
<div className="modal-cards">
{(view.pendingSacrifice.options ?? []).map((id) => {
const c = deck.find((d) => d.id === id)
return c ? (
<CardView
key={id}
card={c}
size="lg"
onClick={() => send({ type: 'sacrificeChoose', card: id })}
/>
) : null
})}
</div>
</div>
</div>
)}
{cardFlyer &&
createPortal(
<div
+3
View File
@@ -173,6 +173,9 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{(p.avocados ?? 0) > 0 && (
<span className="chip" title="Set-aside Avocados">🥑 {p.avocados}</span>
)}
{(p.mana ?? 0) > 0 && (
<span className="chip" title="Mana pool">🔮 {p.mana}</span>
)}
</div>
))}
</div>