Improve UI of food and set-aside cards.

This commit is contained in:
Greyson Parrelli
2026-07-23 11:12:47 -04:00
parent 7bb20e5d2d
commit 235b1d9cc8
6 changed files with 323 additions and 94 deletions
+48 -23
View File
@@ -4,7 +4,6 @@ import { createPortal } from 'react-dom'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import { CardView } from './CardView'
import { DiceRoll, ROLL_MS } from './DiceRoll'
import { artFor } from '../petArt'
// How long a settled rock roll (and its damage) stays on screen before the
// battle advances to the next step.
@@ -30,6 +29,7 @@ interface SideVis {
stack: number
pending: Card[] // foods revealed (or prepped) waiting for a pet
unit: UnitVis | null
setAside: Card[] // fainted pets kept beside the arena with a pending effect
}
// Milliseconds each event type stays on screen during playback.
@@ -47,6 +47,8 @@ const EVENT_MS: Record<BattleEvent['type'], number> = {
steal: 1100,
eat: 1000,
heal: 900,
setaside: 700,
release: 500,
}
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
@@ -55,7 +57,7 @@ const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').le
// 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 }))
const sides: SideVis[] = stackSizes.map((n) => ({ stack: n, pending: [], unit: null, setAside: [] }))
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
@@ -145,6 +147,19 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
}
case 'shield':
break // pure animation; no state change
case 'setaside':
if (ev.card) sides[ev.seat!].setAside.push(ev.card)
break
case 'release': {
// A spent card leaves the play area: a set-aside pet (Turtle) or a
// used-up food perk (Melon), which lives in the pet's food fan.
const s = sides[ev.seat!]
const id = ev.card?.id
s.setAside = s.setAside.filter((c) => c.id !== id)
if (s.unit) s.unit.foods = s.unit.foods.filter((c) => c.id !== id)
s.pending = s.pending.filter((c) => c.id !== id)
break
}
}
}
return sides
@@ -247,13 +262,13 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
const won = battle.winnerSeat === youSeat
const draw = battle.winnerSeat < 0
// Dice for the rock event currently on screen, shown under the thrower's
// side. `step` keys the tray so the scramble replays for every rock event.
// Dice for the rock event currently on screen. renderSide draws them under
// the throwing side's deck; `step` keys the tray so the scramble replays for
// every rock event.
const rockDice =
!done && lastEvent?.type === 'rock' && lastEvent.dice && lastEvent.dice.length > 0
? lastEvent.dice
: null
const rockSide: 'left' | 'right' = lastEvent?.seat === youSeat ? 'left' : 'right'
function renderSide(seat: number, dir: 'left' | 'right') {
const s = sides[seat]
@@ -298,21 +313,43 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
<CardView card={lastEvent.card} size="sm" dead />
</div>
)}
{rockDice && lastEvent?.seat === seat && (
// The dice roll sits directly under the throwing side's deck.
<div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={dir} />
</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>
// Set-aside pets (Blowfish, Badger, …) sit in a row above the active pet
// until their pending effect resolves.
const setAsideEl = s.setAside.length > 0 && (
<div className="setaside-row">
{s.setAside.map((c) => (
<CardView key={c.id} card={c} size="sm" />
))}
</div>
)
// Foods attached to the pet (or, before one is in play, waiting for the
// next) fan out below it: a vertical stack overlapping ~¾, so only each
// card's top edge shows — like cards laid out on a table.
const foods = s.unit ? s.unit.foods : s.pending
const foodFanEl = foods.length > 0 && (
<div className="food-fan">
{foods.map((f, i) => (
<div key={f.id} className="food-fan-card" style={{ zIndex: i + 1 }}>
<CardView card={f} size="sm" />
</div>
))}
</div>
)
const unitEl = (
<div className="battle-unit-zone">
{setAsideEl}
{foodFanEl}
{s.unit && (
<div
key={`${s.unit.card.id}-${clashing || rockVictim ? step : 'idle'}`}
@@ -331,15 +368,6 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
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>
)}
{pop && (
<div key={`pop-${step}`} className={pop.startsWith('') ? 'damage-pop' : 'fx-pop'}>
{pop}
@@ -353,13 +381,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
return dir === 'left' ? (
<div className="battle-side">
{stackEl}
{foodsEl}
{unitEl}
</div>
) : (
<div className="battle-side">
{unitEl}
{foodsEl}
{stackEl}
</div>
)
@@ -423,7 +449,6 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
<span className="battle-center-bolt"></span>
</div>
{renderSide(oppSeat, 'right')}
{rockDice && <DiceRoll key={step} dice={rockDice} side={rockSide} />}
</div>
{peek &&
+2
View File
@@ -22,6 +22,8 @@ const BATTLE_ICONS: Record<BattleEvent['type'], string> = {
steal: '🍎',
eat: '🍎',
heal: '💚',
setaside: '🃏',
release: '↩️',
}
// battleLogLines turns the battle events revealed up to `step` into readable