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>
+21
View File
@@ -23,6 +23,27 @@ const PET_EMOJI: Record<string, string> = {
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': '🐕‍🦺',
// Unicorn pack — Tier 1
Alchemedes: '🧪', 'Cuddle Toad': '🐸', Pengobble: '🐧', Barghest: '🐺', Basilisk: '🐍', Baku: '🦤',
// Unicorn pack — Tier 2
Thunderbird: '🦅', Gargoyle: '🗿', 'Frost Wolf': '🐺', Mothman: '🦋', Nightcrawler: '🐛', Bigfoot: '🦍',
// Unicorn pack — Tier 3
'Fur-Bearing Trout': '🐟', Calygreyhound: '🐕', Tatzelwurm: '🐲', 'Skeleton Dog': '💀', Mandrake: '🌱', 'Lucky Cat': '🐱', Slime: '🟢',
// Unicorn pack — Tier 4
Roc: '🦅', Chimera: '🐐', Kraken: '🦑', Unicorn: '🦄', Abomination: '🧟', Rootlin: '🌿', Fairy: '🧚',
// Unicorn pack — Tier 5
Kitsune: '🦊', Pixiu: '🦁', 'Red Dragon': '🐉', Amalgamation: '🧬', 'Vampire Bat': '🧛', Werewolf: '🐺', 'Loveland Frogman': '🐸',
// Unicorn pack — Tier 6
Sleipnir: '🐎', 'Sea Serpent': '🦕', Bakunawa: '🐍', Manticore: '🦂', 'Team Spirit': '🎏', Behemoth: '🐘', Quetzalcoatl: '🪶',
// Unicorn pack tokens & foods
Mana: '🔮',
Spooked: '👻',
Exposed: '🎯',
'Fairy Dust': '✨',
'Water of Youth': '⏳',
'Health Potion': '❤️‍🩹',
'Big Mana Potion': '🍶',
Cornucopia: '🧺',
// Summons & foods
Bee: '🐝',
Apple: '🍎',
+45
View File
@@ -1392,6 +1392,51 @@ h3 {
color: var(--gold);
}
/* Bigfoot's shop-deck peek (Unicorn pack): a button and the revealed card. */
.peek-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin: 12px auto 0;
width: fit-content;
}
/* Unicorn ailment cards: a cold, spectral palette distinct from foods/pets. */
.card-ailment {
background: linear-gradient(168deg, #e9e6f7, #cfc7ec 55%, #b3a8dd);
}
.card-ailment .card-effect,
.card-ailment .card-name {
color: #45308a;
}
/* Ailment badges pinned to a battle unit. */
.ailment-badges {
position: absolute;
top: -8px;
left: -8px;
display: flex;
gap: 3px;
z-index: 5;
}
.ailment-badge {
font-size: 0.8rem;
font-weight: 800;
line-height: 1;
padding: 3px 5px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.35);
background: rgba(20, 16, 40, 0.82);
color: #fff;
}
.ailment-spooked {
box-shadow: 0 0 6px rgba(150, 130, 255, 0.7);
}
.ailment-exposed {
box-shadow: 0 0 6px rgba(255, 120, 90, 0.7);
}
/* Coins shown as big golden discs above the buy row. */
.shop-coins {
display: flex;
+20 -1
View File
@@ -1,7 +1,7 @@
// Mirrors of the Go view types (internal/game/view.go).
export type Suit = 'red' | 'blue' | 'yellow'
export type CardKind = 'pet' | 'food'
export type CardKind = 'pet' | 'food' | 'ailment'
export type Phase = 'lobby' | 'shop' | 'arrange' | 'battle' | 'gameover'
export interface Card {
@@ -16,6 +16,7 @@ export interface Card {
food?: string
perk?: boolean
temporary?: boolean
ailment?: string // 'spooked' | 'exposed' (Unicorn pack; kind === 'ailment')
}
export interface PlayerView {
@@ -33,6 +34,8 @@ export interface PlayerView {
firstBuyFree?: boolean // Manta Ray: next buy is free (self only)
buysThisRound?: number // Blue-Ringed Octopus counter (self only)
trumpets?: number // banked Trumpets for next battle (Bird of Paradise, self only)
mana?: number // persistent Mana pool (Unicorn pack; public)
shopPeek?: Card // Bigfoot's look at the top of the shop deck (self only)
deck?: Card[]
}
@@ -47,6 +50,16 @@ export interface PendingReveal {
playerId: string
source: string
options?: string[] // eligible pet card ids (buyer only)
apples?: number // fixed apple reward (Quetzalcoatl); 0 = revealed pet's power (Cockatoo)
}
// Water of Youth (Unicorn pack): the buyer must sacrifice one of their pets to
// upgrade into a next-tier card.
export interface PendingSacrifice {
playerId: string
source: string
tier: number
options?: string[] // eligible pet card ids to sacrifice (buyer only)
}
export interface BattleEvent {
@@ -66,6 +79,9 @@ export interface BattleEvent {
| 'release'
| 'trumpet' // a side gains (+) or spends/loses (-) Trumpets (Golden pack)
| 'prevent' // a Cone Snail shaves damage off a hit (Golden pack)
| 'mana' // a side gains (+) or spends (-) Mana (Unicorn pack)
| 'ailment' // a pet gains Ailment cards (Unicorn pack)
| 'bounce' // a pet is sent to the bottom of its deck (Unicorn: Loveland Frogman)
seat?: number
target?: number
card?: Card
@@ -131,6 +147,7 @@ export interface GameView {
players: PlayerView[]
pending?: PendingTrade
pendingReveal?: PendingReveal
pendingSacrifice?: PendingSacrifice
battle?: BattleResult
winnerSeat: number
log?: LogEntry[]
@@ -148,6 +165,8 @@ export type ClientMessage =
| { type: 'trade'; cards: string[] }
| { type: 'tradeChoose'; pick: number }
| { type: 'revealChoose'; card: string }
| { type: 'sacrificeChoose'; card: string }
| { type: 'peek' }
| { type: 'pass' }
| { type: 'arrange'; order: string[] }
| { type: 'ready' }