Add pets for tiers 1-3.

This commit is contained in:
Greyson Parrelli
2026-07-23 00:10:17 -04:00
parent 612a4e6227
commit 8d43bbf61e
14 changed files with 1915 additions and 406 deletions
+11 -3
View File
@@ -17,10 +17,18 @@ export function ArrangePhase({ view, you, send }: Props) {
const dragIndex = useRef<number | null>(null)
const locked = you.ready
// If the server-side deck changes (shouldn't during arrange, but be safe),
// resync.
// Resync only if the deck's actual contents changed — every broadcast
// creates a fresh array, and blindly resetting would wipe an in-progress
// ordering whenever the opponent acts.
useEffect(() => {
setOrder(you.deck ?? [])
setOrder((prev) => {
const deck = you.deck ?? []
const ids = new Set(deck.map((c) => c.id))
if (prev.length === deck.length && prev.every((c) => ids.has(c.id))) {
return prev
}
return deck
})
}, [you.deck])
function move(from: number, to: number) {
+208 -102
View File
@@ -1,123 +1,217 @@
import { useEffect, useMemo, useState } from 'react'
import type { BattleEvent, BattleUnit, ClientMessage, GameView } from '../types'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import { CardView } from './CardView'
import { artFor } from '../petArt'
interface Props {
view: GameView
send: (msg: ClientMessage) => void
}
const STEP_MS = 1400
interface UnitState {
unit: BattleUnit
index: number
interface UnitVis {
card: Card
foods: Card[]
bonus: number
damage: number
dead: boolean // died in an earlier step (gone)
dying: boolean // died in the step just played (animate out)
dying: boolean
}
// applyEvents replays the first `step` events onto a seat's lineup.
function applyEvents(view: GameView, seat: number, step: number): UnitState[] {
const battle = view.battle!
const events = battle.events ?? []
const states: UnitState[] = (battle.lineups[seat] ?? []).map((u, i) => ({
unit: u,
index: i,
damage: 0,
dead: false,
dying: false,
}))
const seatPos = seat === 0 ? 0 : 1
for (let k = 0; k < step && k < events.length; k++) {
interface SideVis {
stack: number
pending: Card[] // revealed foods waiting for a pet
unit: UnitVis | null
}
// Milliseconds each event type stays on screen during playback.
const EVENT_MS: Record<BattleEvent['type'], number> = {
reveal: 800,
summon: 1000,
rock: 1200,
clash: 1400,
eat: 1000,
}
// 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.
function replay(events: BattleEvent[], stackSizes: number[], upto: number): SideVis[] {
const sides: SideVis[] = stackSizes.map((n) => ({ stack: n, pending: [], unit: null }))
for (let k = 0; k < upto && k < events.length; k++) {
for (const s of sides) {
if (s.unit?.dying) s.unit = null // clear last step's casualties
}
const ev = events[k]
const s = states[ev.units[seatPos]]
if (!s) continue
s.damage = ev.damage[seatPos]
if (ev.died[seatPos]) {
s.dying = k === step - 1
s.dead = k < step - 1
switch (ev.type) {
case 'reveal': {
const s = sides[ev.seat!]
s.stack--
const card = ev.card!
if (card.kind === 'food') {
s.pending.push(card)
} else {
s.unit = {
card,
foods: s.pending,
bonus: s.pending.filter((f) => f.food === 'apple').length,
damage: 0,
dying: false,
}
s.pending = []
}
break
}
case 'summon':
sides[ev.seat!].stack++
break
case 'rock': {
const u = sides[ev.target!].unit
if (u) {
u.damage = ev.damageAfter ?? u.damage
if (ev.targetDied) u.dying = true
}
break
}
case 'clash':
sides.forEach((s, seat) => {
if (!s.unit) return
s.unit.damage = ev.damage?.[seat] ?? s.unit.damage
if (ev.died?.[seat]) s.unit.dying = true
})
break
case 'eat': {
const u = sides[ev.seat!].unit
if (u) u.bonus = ev.bonus ?? u.bonus
break
}
}
}
return states
return sides
}
// BattlePhase plays back the battle log: front pets clash, damage numbers
// fly, the fallen fade out, then the round result lands.
// 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) {
const battle = view.battle!
const events = battle.events ?? []
const [step, setStep] = useState(0)
const [acked, setAcked] = useState(false)
const done = step >= events.length
const lastEvent = step > 0 ? events[step - 1] : null
useEffect(() => {
if (done) return
const t = window.setTimeout(() => setStep((s) => s + 1), STEP_MS)
const delay = EVENT_MS[events[step].type] ?? 1000
const t = window.setTimeout(() => setStep((s) => s + 1), delay)
return () => window.clearTimeout(t)
}, [step, done])
}, [step, done, events])
const sides = useMemo(
() => replay(events, battle.stackSizes, step),
[events, battle.stackSizes, step],
)
const youSeat = view.youSeat
const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1
const yourLine = useMemo(
() => applyEvents(view, youSeat, step),
[view, youSeat, step],
)
const oppLine = useMemo(
() => applyEvents(view, oppSeat, step),
[view, oppSeat, step],
)
const lastEvent = step > 0 ? events[step - 1] : null
function renderSide(line: UnitState[], side: 'left' | 'right', seat: number) {
const seatPos = seat === 0 ? 0 : 1
const frontIdx = line.find((s) => !s.dead && !s.dying)?.index
return (
<div className={`battle-side battle-side-${side}`}>
{line
.filter((s) => !s.dead)
.map((s) => {
const isFront = s.index === frontIdx
const clashing =
!done && lastEvent !== null && lastEvent.units[seatPos] === s.index
return (
<div
key={`${s.unit.card.id}-${clashing ? step : 'idle'}`}
className={[
'battle-unit',
clashing ? `clash-${side}` : '',
s.dying ? 'unit-dying' : '',
isFront && !s.dying ? 'is-front' : '',
]
.filter(Boolean)
.join(' ')}
>
<CardView
card={s.unit.card}
bonus={s.unit.bonus}
damage={s.damage}
dead={s.dying}
/>
{clashing && (
<div className="damage-pop">
{lastEvent!.damage[seatPos] -
prevDamage(events, step - 1, seatPos, s.index)}
</div>
)}
</div>
)
})}
</div>
)
}
const you = view.players[youSeat]
const opp = view.players[oppSeat]
const won = battle.winnerSeat === youSeat
const draw = battle.winnerSeat < 0
function renderSide(seat: number, dir: 'left' | 'right') {
const s = sides[seat]
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 revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat
const stackEl = (
<div className="stackpile">
{s.stack > 0 ? (
<div className="card-back">
<span className="card-back-count">{s.stack}</span>
</div>
) : (
<div className="card-slot-empty stack-empty" />
)}
{summoning && lastEvent?.card && (
<div className="summon-pop">
<CardView card={lastEvent.card} size="sm" />
</div>
)}
</div>
)
const foodsEl = (
<div className="pending-foods">
{s.pending.map((f) => (
<span key={f.id} className="food-chip" title={f.name}>
{artFor(f.name)}
</span>
))}
</div>
)
const unitEl = (
<div className="battle-unit-zone">
{s.unit && (
<div
key={`${s.unit.card.id}-${clashing || rockVictim ? step : 'idle'}`}
className={[
'battle-unit',
clashing || clashDying ? `clash-${dir}` : '',
s.unit.dying ? 'unit-dying' : '',
revealing ? 'unit-reveal' : '',
]
.filter(Boolean)
.join(' ')}
>
<CardView
card={s.unit.card}
bonus={s.unit.bonus}
damage={s.unit.damage}
dead={s.unit.dying}
/>
{s.unit.foods.length > 0 && (
<div className="unit-foods">
{s.unit.foods.map((f) => (
<span key={f.id} title={f.name}>
{artFor(f.name)}
</span>
))}
</div>
)}
{(lastEvent?.type === 'clash' || rockVictim) && !done && (
<div className="damage-pop">
{lastEvent?.type === 'rock'
? lastEvent.roll === 0
? 'miss!'
: `${lastEvent.roll}`
: `${clashDamageTaken(events, step - 1, seat)}`}
</div>
)}
{eating && <div className="eat-pop">🍎 +1</div>}
</div>
)}
</div>
)
return dir === 'left' ? (
<div className="battle-side">
{stackEl}
{foodsEl}
{unitEl}
</div>
) : (
<div className="battle-side">
{unitEl}
{foodsEl}
{stackEl}
</div>
)
}
return (
<div className="battle">
<div className="battle-header">
@@ -136,11 +230,16 @@ export function BattlePhase({ view, send }: Props) {
</div>
<div className="battlefield">
{renderSide(yourLine, 'left', youSeat)}
{renderSide(youSeat, 'left')}
<div className="battle-center" aria-hidden>
{!done && lastEvent?.type === 'rock' && (
<div className={`rock-fly ${lastEvent.target === youSeat ? 'rock-fly-left' : 'rock-fly-right'}`}>
🪨
</div>
)}
<span className="battle-center-bolt"></span>
</div>
{renderSide(oppLine, 'right', oppSeat)}
{renderSide(oppSeat, 'right')}
</div>
{done && (
@@ -174,18 +273,25 @@ export function BattlePhase({ view, send }: Props) {
)
}
// prevDamage finds the damage a unit had before the given event, so the
// floating number shows just this clash's hit.
function prevDamage(
events: BattleEvent[],
upto: number,
seatPos: number,
unitIndex: number,
): number {
let dmg = 0
for (let k = 0; k < upto; k++) {
const ev = events[k]
if (ev.units[seatPos] === unitIndex) dmg = ev.damage[seatPos]
// 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 {
const ev = events[idx]
if (ev?.type !== 'clash') return 0
const after = ev.damage?.[seat] ?? 0
// Walk back to the pet's damage before this clash.
let before = 0
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) {
before = e.damageAfter ?? 0
break
}
if (e.type === 'clash') {
before = e.damage?.[seat] ?? 0
break
}
}
return dmg
return after - before
}
+16 -14
View File
@@ -1,5 +1,5 @@
import type { Card } from '../types'
import { artFor, SUIT_EMOJI } from '../petArt'
import { artFor } from '../petArt'
interface Props {
card: Card
@@ -13,8 +13,9 @@ interface Props {
dead?: boolean
}
// CardView renders one physical card: pets get a power badge and suit stamp,
// foods a description line. Battle mode layers on buffs and damage markers.
// CardView renders one physical card: pets get a power badge and a colored
// suit dot, foods a description line. Battle mode layers on buffs and damage
// markers.
export function CardView({
card,
size = 'md',
@@ -45,24 +46,25 @@ export function CardView({
role={onClick ? 'button' : undefined}
>
<div className="card-top">
<span className="card-tier">T{card.tier || ''}</span>
{card.suit && (
<span className="card-suit" title={`Suit: ${card.suit}`}>
{SUIT_EMOJI[card.suit]}
</span>
)}
<span className="card-tier">{card.tier ? `T${card.tier}` : '·'}</span>
{card.suit && <span className={`suit-dot suit-${card.suit}`} title={`${card.suit} suit`} />}
</div>
<div className="card-art" aria-hidden>
{artFor(card.name)}
</div>
<div className="card-name">{card.name}</div>
{card.kind === 'pet' ? (
<div className="card-bottom">
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
{damage > 0 && !dead && <span className="card-damage">{damage}</span>}
</div>
<>
{card.effectText && <div className="card-effect">{card.effectText}</div>}
<div className="card-bottom">
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
{damage > 0 && !dead && <span className="card-damage">{damage}</span>}
</div>
</>
) : (
<div className="card-food-text">+1 power</div>
<div className="card-effect">
{card.effectText ?? (card.food === 'apple' ? '+1 power (this battle)' : '')}
</div>
)}
{selected && <div className="card-check"></div>}
</div>
+14 -10
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView'
import { SUIT_EMOJI } from '../petArt'
interface Props {
view: GameView
@@ -55,12 +54,12 @@ export function ShopPhase({ view, you, send }: Props) {
{cleanup ? (
excessPets > 0 ? (
<span className="status-hot">
Too many pets! Discard <strong>{excessPets}</strong> they become
Too many pets! Sell <strong>{excessPets}</strong> they become
apples 🍎
</span>
) : (
<span className="muted">
Waiting for {opponent?.name ?? 'opponent'} to discard down to{' '}
Waiting for {opponent?.name ?? 'opponent'} to sell down to{' '}
{view.maxPets} pets
</span>
)
@@ -135,9 +134,9 @@ export function ShopPhase({ view, you, send }: Props) {
<button
className="btn btn-primary"
disabled={!cleanupReady}
onClick={() => act({ type: 'discard', cards: selected })}
onClick={() => act({ type: 'sell', cards: selected })}
>
Discard {excessPets} pet{excessPets > 1 ? 's' : ''} 🍎
Sell {excessPets} pet{excessPets > 1 ? 's' : ''} 🍎
</button>
)
) : (
@@ -145,10 +144,10 @@ export function ShopPhase({ view, you, send }: Props) {
<button
className="btn btn-secondary"
disabled={!myTurn || selected.length === 0}
onClick={() => act({ type: 'discard', cards: selected })}
title="Convert selected cards into apples (+1 power each)"
onClick={() => act({ type: 'sell', cards: selected })}
title="Convert selected cards into apples (+1 power each, this battle only)"
>
Discard {selected.length > 0 ? selected.length : ''} 🍎 (1 🪙)
Sell {selected.length > 0 ? selected.length : ''} 🍎 (1 🪙)
</button>
<button
className="btn btn-secondary"
@@ -156,8 +155,13 @@ export function ShopPhase({ view, you, send }: Props) {
onClick={() => act({ type: 'trade', cards: selected })}
title="Trade 3 same-suit pets for a pick from the next tier"
>
Trade 3 {sameSuit && selectedCards[0].suit ? SUIT_EMOJI[selectedCards[0].suit] : 'matching'} Tier{' '}
{Math.min(view.round + 1, view.maxRounds)} (1 🪙)
Trade 3{' '}
{sameSuit && selectedCards[0].suit ? (
<span className={`suit-dot suit-${selectedCards[0].suit}`} />
) : (
'matching'
)}{' '}
Tier {Math.min(view.round + 1, view.maxRounds)} (1 🪙)
</button>
<button
className="btn btn-ghost"
+10 -22
View File
@@ -1,35 +1,23 @@
import type { Suit } from './types'
const PET_EMOJI: Record<string, string> = {
// Tier 1
Ant: '🐜', Cricket: '🦗', Fish: '🐟', Horse: '🐴',
Beaver: '🦫', Otter: '🦦', Pig: '🐷', Mosquito: '🦟',
Ant: '🐜', Cricket: '🦗', Duck: '🦆', Otter: '🦦', Mosquito: '🦟', Fish: '🐟',
// Tier 2
Crab: '🦀', Swan: '🦢', Hedgehog: '🦔', Peacock: '🦚',
Flamingo: '🦩', Rat: '🐀', Shrimp: '🦐', Spider: '🕷️',
Worm: '🪱', Flamingo: '🦩', Peacock: '🦚', Swan: '🦢', Rat: '🐀', Spider: '🕷️',
// Tier 3
Dog: '🐶', Badger: '🦡', Camel: '🐫', Giraffe: '🦒',
Kangaroo: '🦘', Ox: '🐂', Rabbit: '🐰', Sheep: '🐑',
Dog: '🐶', Dolphin: '🐬', Giraffe: '🦒', Camel: '🐫', Sheep: '🐑', Dodo: '🦤', Badger: '🦡',
// Tier 4
Skunk: '🦨', Hippo: '🦛', Bison: '🦬', Deer: '🦌',
Squirrel: '🐿️', Whale: '🐳', Worm: '🪱', Penguin: '🐧',
Skunk: '🦨', Hippo: '🦛', Bison: '🦬', Deer: '🦌', Squirrel: '🐿️', Whale: '🐳',
// Tier 5
Scorpion: '🦂', Rhino: '🦏', Monkey: '🐒', Cow: '🐄',
Seal: '🦭', Shark: '🦈', Turkey: '🦃', Crocodile: '🐊',
Scorpion: '🦂', Rhino: '🦏', Monkey: '🐒', Cow: '🐄', Seal: '🦭', Shark: '🦈',
// Tier 6
Leopard: '🐆', Boar: '🐗', Fly: '🪰', Gorilla: '🦍',
Mammoth: '🦣', Snake: '🐍', Tiger: '🐯', Dragon: '🐉',
// Foods
Leopard: '🐆', Boar: '🐗', Gorilla: '🦍', Mammoth: '🦣', Snake: '🐍', Tiger: '🐯',
// Summons & foods
Bee: '🐝',
Apple: '🍎',
Honey: '🍯',
Garlic: '🧄',
}
export function artFor(name: string): string {
return PET_EMOJI[name] ?? '🐾'
}
export const SUIT_EMOJI: Record<Suit, string> = {
sun: '☀️',
moon: '🌙',
star: '⭐',
leaf: '🍃',
}
+239 -24
View File
@@ -436,30 +436,69 @@ h3 {
}
.card-art {
font-size: 3rem;
line-height: 1.25;
font-size: 2.4rem;
line-height: 1.2;
filter: drop-shadow(0 3px 2px rgba(0, 0, 0, 0.25));
}
.card-lg .card-art {
font-size: 3.6rem;
font-size: 3rem;
}
.card-sm .card-art {
font-size: 2.2rem;
font-size: 1.8rem;
}
.card-name {
font-family: var(--font-display);
font-size: 0.85rem;
margin-top: auto;
font-size: 0.8rem;
margin-top: 1px;
}
.card-effect {
font-size: 0.56rem;
line-height: 1.25;
font-weight: 700;
color: rgba(51, 35, 15, 0.75);
text-align: center;
padding: 0 2px;
overflow: hidden;
}
.card-lg .card-effect {
font-size: 0.62rem;
}
.card-sm .card-effect {
display: none;
}
.card-bottom {
display: flex;
gap: 6px;
align-items: center;
margin-top: 2px;
margin-top: auto;
}
.suit-dot {
display: inline-block;
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid rgba(0, 0, 0, 0.35);
vertical-align: middle;
}
.suit-red {
background: #e2483d;
}
.suit-blue {
background: #3d7de2;
}
.suit-yellow {
background: #f4c430;
}
.card-power {
@@ -490,17 +529,14 @@ h3 {
font-size: 0.8rem;
}
.card-food-text {
font-size: 0.72rem;
font-weight: 700;
color: #7a2e1e;
margin-top: 2px;
}
.card-food {
background: linear-gradient(180deg, #ffe9e0, #ffd4c2);
}
.card-food .card-effect {
color: #7a2e1e;
}
.card-check {
position: absolute;
top: -10px;
@@ -708,26 +744,123 @@ h3 {
}
.battle-center {
position: relative;
font-size: 1.6rem;
opacity: 0.5;
flex-shrink: 0;
display: grid;
place-items: center;
min-width: 2rem;
}
.battle-center-bolt {
opacity: 0.45;
}
.battle-side {
display: flex;
gap: 10px;
gap: 12px;
align-items: center;
flex: 1;
min-width: 0;
justify-content: center;
}
/* Front units meet in the middle: left side is reversed so index 0 sits
next to the center. */
.battle-side-left {
flex-direction: row-reverse;
/* --- deck stacks --- */
.stackpile {
position: relative;
flex-shrink: 0;
}
.battle-side-right {
flex-direction: row;
.card-back {
width: 84px;
height: 116px;
border-radius: var(--card-radius);
border: 3px solid var(--cocoa);
background:
repeating-linear-gradient(
45deg,
#b3552b 0 10px,
#a34a22 10px 20px
);
box-shadow:
2px 2px 0 rgba(0, 0, 0, 0.25),
4px 4px 0 rgba(0, 0, 0, 0.15);
display: grid;
place-items: center;
}
.card-back-count {
font-family: var(--font-display);
font-size: 1.3rem;
color: var(--cream);
background: rgba(0, 0, 0, 0.35);
border-radius: 50%;
width: 38px;
height: 38px;
display: grid;
place-items: center;
}
.stack-empty {
width: 84px;
height: 116px;
}
@keyframes summon-pop {
0% {
opacity: 0;
transform: translateY(18px) scale(0.6);
}
30% {
opacity: 1;
transform: translateY(-14px) scale(1.05);
}
80% {
opacity: 1;
transform: translateY(-10px) scale(1);
}
100% {
opacity: 0;
transform: translateY(0) scale(0.7);
}
}
.summon-pop {
position: absolute;
top: -30px;
left: 50%;
margin-left: -42px;
z-index: 6;
animation: summon-pop 1000ms ease forwards;
pointer-events: none;
}
/* --- foods waiting for a pet --- */
.pending-foods {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 1.3rem;
min-width: 1.5rem;
}
.unit-foods {
display: flex;
justify-content: center;
gap: 2px;
font-size: 1rem;
margin-top: 4px;
}
/* --- the pet in play --- */
.battle-unit-zone {
min-width: 100px;
min-height: 150px;
display: grid;
place-items: center;
}
.battle-unit {
@@ -740,8 +873,90 @@ h3 {
height: 132px;
}
.battle-unit.is-front .card {
outline: 3px solid rgba(255, 207, 92, 0.6);
@keyframes unit-reveal {
from {
opacity: 0;
transform: rotateY(90deg) scale(0.8);
}
to {
opacity: 1;
transform: rotateY(0) scale(1);
}
}
.battle-unit.unit-reveal {
animation: unit-reveal 500ms ease;
}
@keyframes rock-fly-right {
0% {
opacity: 0;
transform: translateX(-70px) translateY(-10px) rotate(0);
}
20% {
opacity: 1;
}
100% {
opacity: 1;
transform: translateX(70px) translateY(4px) rotate(360deg);
}
}
@keyframes rock-fly-left {
0% {
opacity: 0;
transform: translateX(70px) translateY(-10px) rotate(0);
}
20% {
opacity: 1;
}
100% {
opacity: 1;
transform: translateX(-70px) translateY(4px) rotate(-360deg);
}
}
.rock-fly {
position: absolute;
font-size: 1.4rem;
z-index: 7;
pointer-events: none;
}
.rock-fly-right {
animation: rock-fly-right 700ms ease-in forwards;
}
.rock-fly-left {
animation: rock-fly-left 700ms ease-in forwards;
}
@keyframes eat-pop {
0% {
opacity: 0;
transform: translate(-50%, 6px) scale(0.6);
}
30% {
opacity: 1;
transform: translate(-50%, -14px) scale(1.2);
}
100% {
opacity: 0;
transform: translate(-50%, -34px) scale(1);
}
}
.eat-pop {
position: absolute;
top: 0;
left: 50%;
font-family: var(--font-display);
font-size: 1rem;
color: #7be495;
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5);
animation: eat-pop 1000ms ease forwards;
pointer-events: none;
z-index: 5;
}
@keyframes clash-left {
+21 -17
View File
@@ -1,6 +1,6 @@
// Mirrors of the Go view types (internal/game/view.go).
export type Suit = 'sun' | 'moon' | 'star' | 'leaf'
export type Suit = 'red' | 'blue' | 'yellow'
export type CardKind = 'pet' | 'food'
export type Phase = 'lobby' | 'shop' | 'cleanup' | 'arrange' | 'battle' | 'gameover'
@@ -8,11 +8,14 @@ export interface Card {
id: string
kind: CardKind
name: string
tier: number
tier?: number
power?: number
suit?: Suit
effect?: string
effects?: unknown[]
effectText?: string
food?: string
perk?: boolean
temporary?: boolean
}
export interface PlayerView {
@@ -34,24 +37,25 @@ export interface PendingTrade {
options: [Card, Card]
}
export interface BattleUnit {
card: Card
foods: Card[] | null
bonus: number
damage: number
}
export interface BattleEvent {
type: 'clash'
units: number[]
damage: number[]
died: boolean[]
type: 'reveal' | 'summon' | 'rock' | 'clash' | 'eat'
seat?: number
target?: number
card?: Card
// clash
damage?: number[]
died?: boolean[]
// rock
roll?: number
damageAfter?: number
targetDied?: boolean
// eat
bonus?: number
}
export interface BattleResult {
round: number
lineups: BattleUnit[][]
wastedFoods: (Card[] | null)[]
stackSizes: number[]
events: BattleEvent[] | null
winnerSeat: number
trophies: number
@@ -76,7 +80,7 @@ export interface GameView {
export type ClientMessage =
| { type: 'buy'; row: number }
| { type: 'discard'; cards: string[] }
| { type: 'sell'; cards: string[] }
| { type: 'trade'; cards: string[] }
| { type: 'tradeChoose'; pick: number }
| { type: 'pass' }