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"
|
||||
|
||||
+10
-22
@@ -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
@@ -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
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user