Improve UI of food and set-aside cards.
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
+50
-32
@@ -1197,8 +1197,11 @@ h3 {
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.18));
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
border-radius: 20px;
|
||||
padding: 30px 14px;
|
||||
min-height: 244px;
|
||||
/* Roomy top/bottom padding: pets keep their central row while set-aside
|
||||
cards fan above and attached foods fan below. The bottom needs more room
|
||||
since a pet can carry several foods. */
|
||||
padding: 132px 14px 208px;
|
||||
min-height: 150px;
|
||||
overflow-x: auto;
|
||||
box-shadow:
|
||||
var(--tray-inset),
|
||||
@@ -1388,33 +1391,49 @@ h3 {
|
||||
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
/* --- 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 {
|
||||
position: relative;
|
||||
min-width: 100px;
|
||||
min-height: 150px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/* Set-aside pets (Blowfish, Badger, …) laid out in a row above the active
|
||||
pet, kept until their pending effect resolves. */
|
||||
.setaside-row {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Attached (or pending) foods fanned below the pet: a vertical stack that
|
||||
overlaps ~¾ so only each card's top edge shows, last card fully on top. */
|
||||
.food-fan {
|
||||
position: absolute;
|
||||
top: calc(100% - 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.food-fan-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.food-fan-card:not(:first-child) {
|
||||
margin-top: -88px;
|
||||
}
|
||||
|
||||
.battle-unit {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
@@ -1442,22 +1461,21 @@ h3 {
|
||||
|
||||
/* --- rock dice --- */
|
||||
|
||||
/* A little tray of dice under the thrower's side. Fixed slots: the dice
|
||||
scramble in place, then settle showing the rolled rock faces. */
|
||||
.dice-roll {
|
||||
/* A little tray of dice, drawn just below the throwing side's deck (see
|
||||
.stack-dice). Fixed slots: the dice scramble in place, then settle showing
|
||||
the rolled rock faces. */
|
||||
.stack-dice {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
top: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 8;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.dice-roll-left {
|
||||
left: 27%;
|
||||
}
|
||||
.dice-roll-right {
|
||||
left: 73%;
|
||||
|
||||
.dice-roll {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.droll {
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface BattleEvent {
|
||||
| 'steal'
|
||||
| 'eat'
|
||||
| 'heal'
|
||||
| 'setaside'
|
||||
| 'release'
|
||||
seat?: number
|
||||
target?: number
|
||||
card?: Card
|
||||
|
||||
Reference in New Issue
Block a user