Add pets for tiers 1-3.
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user