Add pets for tiers 4-6.

This commit is contained in:
Greyson Parrelli
2026-07-23 00:10:20 -04:00
parent 8d43bbf61e
commit 10ac457e2e
11 changed files with 1334 additions and 163 deletions
+92 -12
View File
@@ -18,19 +18,27 @@ interface UnitVis {
interface SideVis {
stack: number
pending: Card[] // revealed foods waiting for a pet
pending: Card[] // foods revealed (or prepped) waiting for a pet
unit: UnitVis | null
}
// Milliseconds each event type stays on screen during playback.
const EVENT_MS: Record<BattleEvent['type'], number> = {
prep: 700,
reveal: 800,
summon: 1000,
mill: 700,
rock: 1200,
clash: 1400,
shield: 900,
strip: 1100,
steal: 1100,
eat: 1000,
heal: 900,
}
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
// replay applies the first `upto` events to fresh stacks and returns each
// seat's visual state. Units that died in the last applied event are still
// present with dying=true so they can animate out.
@@ -42,6 +50,9 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
}
const ev = events[k]
switch (ev.type) {
case 'prep':
sides[ev.seat!].pending.push(ev.card!)
break
case 'reveal': {
const s = sides[ev.seat!]
s.stack--
@@ -52,7 +63,7 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
s.unit = {
card,
foods: s.pending,
bonus: s.pending.filter((f) => f.food === 'apple').length,
bonus: ev.bonus ?? appleCount(s.pending),
damage: 0,
dying: false,
}
@@ -63,6 +74,9 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
case 'summon':
sides[ev.seat!].stack++
break
case 'mill':
sides[ev.seat!].stack--
break
case 'rock': {
const u = sides[ev.target!].unit
if (u) {
@@ -78,16 +92,81 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
if (ev.died?.[seat]) s.unit.dying = true
})
break
case 'strip': {
const u = sides[ev.target!].unit
if (u) {
u.bonus -= appleCount(u.foods)
u.foods = []
if (ev.targetDied) u.dying = true
}
break
}
case 'steal': {
const from = sides[ev.target!].unit
const to = sides[ev.seat!].unit
const n = ev.count ?? 0
if (from && to) {
let moved = 0
from.foods = from.foods.filter((f) => {
if (f.food === 'apple' && moved < n) {
moved++
to.foods.push(f)
return false
}
return true
})
from.bonus -= moved
to.bonus += moved
if (ev.targetDied) from.dying = true
}
break
}
case 'eat': {
const u = sides[ev.seat!].unit
if (u) u.bonus = ev.bonus ?? u.bonus
break
}
case 'heal': {
const u = sides[ev.seat!].unit
if (u) u.damage = ev.damageAfter ?? u.damage
break
}
case 'shield':
break // pure animation; no state change
}
}
return sides
}
// unitPop decides the floating effect text over a seat's pet for the event
// currently playing. Null = nothing.
function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], step: number): string | null {
if (!ev) return null
switch (ev.type) {
case 'rock':
if (ev.target !== seat) return null
return ev.roll === 0 ? 'miss!' : `${ev.roll}`
case 'clash': {
const taken = clashDamageTaken(events, step - 1, seat)
return taken > 0 ? `${taken}` : null
}
case 'shield':
return ev.seat === seat ? '🛡️' : null
case 'eat':
return ev.seat === seat ? '🍎' : null
case 'heal':
return ev.seat === seat ? '💚 +1' : null
case 'strip':
return ev.target === seat ? '💨' : null
case 'steal':
if (ev.seat === seat) return `+🍎×${ev.count}`
if (ev.target === seat) return `−🍎×${ev.count}`
return null
default:
return null
}
}
// BattlePhase plays back the battle log: cards flip off each deck, rocks
// fly, pets clash, the fallen fade out, then the round result lands.
export function BattlePhase({ view, send }: Props) {
@@ -122,9 +201,10 @@ export function BattlePhase({ view, send }: Props) {
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat
const eating = lastEvent?.type === 'eat' && lastEvent.seat === seat
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat
const pop = done ? null : unitPop(lastEvent, seat, events, step)
const stackEl = (
<div className="stackpile">
@@ -140,6 +220,11 @@ export function BattlePhase({ view, send }: Props) {
<CardView card={lastEvent.card} size="sm" />
</div>
)}
{milling && lastEvent?.card && (
<div className="summon-pop mill-pop">
<CardView card={lastEvent.card} size="sm" dead />
</div>
)}
</div>
)
@@ -182,16 +267,11 @@ export function BattlePhase({ view, send }: Props) {
))}
</div>
)}
{(lastEvent?.type === 'clash' || rockVictim) && !done && (
<div className="damage-pop">
{lastEvent?.type === 'rock'
? lastEvent.roll === 0
? 'miss!'
: `${lastEvent.roll}`
: `${clashDamageTaken(events, step - 1, seat)}`}
{pop && (
<div key={`pop-${step}`} className={pop.startsWith('') ? 'damage-pop' : 'fx-pop'}>
{pop}
</div>
)}
{eating && <div className="eat-pop">🍎 +1</div>}
</div>
)}
</div>
@@ -284,7 +364,7 @@ function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): num
for (let k = idx - 1; k >= 0; k--) {
const e = events[k]
if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break
if (e.type === 'rock' && e.target === seat) {
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === seat) {
before = e.damageAfter ?? 0
break
}
+6 -3
View File
@@ -6,16 +6,19 @@ const PET_EMOJI: Record<string, string> = {
// Tier 3
Dog: '🐶', Dolphin: '🐬', Giraffe: '🦒', Camel: '🐫', Sheep: '🐑', Dodo: '🦤', Badger: '🦡',
// Tier 4
Skunk: '🦨', Hippo: '🦛', Bison: '🦬', Deer: '🦌', Squirrel: '🐿️', Whale: '🐳',
Squirrel: '🐿️', Turtle: '🐢', Rooster: '🐓', Bison: '🦬', Blowfish: '🐡', Skunk: '🦨', Hippo: '🦛',
// Tier 5
Scorpion: '🦂', Rhino: '🦏', Monkey: '🐒', Cow: '🐄', Seal: '🦭', Shark: '🦈',
Monkey: '🐒', Rhino: '🦏', Crocodile: '🐊', Scorpion: '🦂', Seal: '🦭', Shark: '🦈', Turkey: '🦃',
// Tier 6
Leopard: '🐆', Boar: '🐗', Gorilla: '🦍', Mammoth: '🦣', Snake: '🐍', Tiger: '🐯',
Gorilla: '🦍', Fly: '🪰', Leopard: '🐆', Mammoth: '🦣', Cat: '🐱', Snake: '🐍', Wolverine: '🐺',
// Summons & foods
Bee: '🐝',
Apple: '🍎',
Honey: '🍯',
Garlic: '🧄',
Pineapple: '🍍',
Chili: '🌶️',
Melon: '🍈',
}
export function artFor(name: string): string {
+8 -3
View File
@@ -931,7 +931,7 @@ h3 {
animation: rock-fly-left 700ms ease-in forwards;
}
@keyframes eat-pop {
@keyframes fx-pop {
0% {
opacity: 0;
transform: translate(-50%, 6px) scale(0.6);
@@ -946,7 +946,7 @@ h3 {
}
}
.eat-pop {
.fx-pop {
position: absolute;
top: 0;
left: 50%;
@@ -954,9 +954,14 @@ h3 {
font-size: 1rem;
color: #7be495;
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5);
animation: eat-pop 1000ms ease forwards;
animation: fx-pop 1000ms ease forwards;
pointer-events: none;
z-index: 5;
white-space: nowrap;
}
.mill-pop .card {
filter: grayscale(1);
}
@keyframes clash-left {
+15 -3
View File
@@ -38,18 +38,30 @@ export interface PendingTrade {
}
export interface BattleEvent {
type: 'reveal' | 'summon' | 'rock' | 'clash' | 'eat'
type:
| 'prep'
| 'reveal'
| 'summon'
| 'mill'
| 'rock'
| 'clash'
| 'shield'
| 'strip'
| 'steal'
| 'eat'
| 'heal'
seat?: number
target?: number
card?: Card
count?: number
// clash
damage?: number[]
died?: boolean[]
// rock
// rock / strip / steal
roll?: number
damageAfter?: number
targetDied?: boolean
// eat
// eat (new bonus total) / reveal (starting bonus)
bonus?: number
}