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}