Fix a bunch of bugs.
This commit is contained in:
+20
-6
@@ -671,15 +671,19 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hurt fires Hurt effects on a pet that was damaged and survived.
|
// hurt fires Hurt effects on a pet that took damage. It fires even when the
|
||||||
|
// hit was fatal — the Lizard drops its Bee even when killed in one shot —
|
||||||
|
// except for self-buffs marked SurviveOnly (Peacock, Gorilla), which need
|
||||||
|
// the pet to live on.
|
||||||
hurt := func(seat int, u *BattleUnit) {
|
hurt := func(seat int, u *BattleUnit) {
|
||||||
if !u.Alive() {
|
alive := u.Alive()
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, e := range u.effects() {
|
for _, e := range u.effects() {
|
||||||
if e.Trigger != TriggerHurt || !allowed(e, u) {
|
if e.Trigger != TriggerHurt || !allowed(e, u) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !alive && e.SurviveOnly {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !spend(seat, e, u.Card.Name) {
|
if !spend(seat, e, u.Card.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -787,6 +791,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
|
|||||||
emitPrevent(target, tu.Card.Name, prev)
|
emitPrevent(target, tu.Card.Name, prev)
|
||||||
}
|
}
|
||||||
if died {
|
if died {
|
||||||
|
// Hurt still fires on a fatal hit (Lizard's Bee), before the faint.
|
||||||
|
if dealt > 0 {
|
||||||
|
hurt(target, tu)
|
||||||
|
}
|
||||||
faint(target, tu)
|
faint(target, tu)
|
||||||
sides[target].unit = nil
|
sides[target].unit = nil
|
||||||
return true
|
return true
|
||||||
@@ -994,8 +1002,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
|
|||||||
// when its own side is already out (Crocodile's last-pet volley).
|
// when its own side is already out (Crocodile's last-pet volley).
|
||||||
anyDeath := false
|
anyDeath := false
|
||||||
for _, q := range plays {
|
for _, q := range plays {
|
||||||
// Effects sourced from a specific unit fizzle if it's gone.
|
// Effects sourced from a specific unit fizzle if it's gone — unless
|
||||||
if q.unit != nil && sides[q.seat].unit != q.unit {
|
// they're posthumous (the Manatee still adds its Apples after rocking
|
||||||
|
// itself to death).
|
||||||
|
if q.unit != nil && sides[q.seat].unit != q.unit && !q.effect.Posthumous {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if q.unit != nil && !allowed(q.effect, q.unit) {
|
if q.unit != nil && !allowed(q.effect, q.unit) {
|
||||||
@@ -1293,6 +1303,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
|
|||||||
dealt := []int{dealtA, dealtB}
|
dealt := []int{dealtA, dealtB}
|
||||||
for seat, u := range []*BattleUnit{ua, ub} {
|
for seat, u := range []*BattleUnit{ua, ub} {
|
||||||
if !u.Alive() {
|
if !u.Alive() {
|
||||||
|
// Hurt still fires on a fatal clash (Lizard's Bee), before the faint.
|
||||||
|
if dealt[seat] > 0 {
|
||||||
|
hurt(seat, u)
|
||||||
|
}
|
||||||
faint(seat, u)
|
faint(seat, u)
|
||||||
sides[seat].unit = nil
|
sides[seat].unit = nil
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -141,6 +141,38 @@ func TestBattleDamageMarkers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Hurt effect still fires on a fatal hit: the Lizard drops a Bee on top of
|
||||||
|
// its deck even when killed in one clash.
|
||||||
|
func TestLizardBeeOnFatalHit(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Lizard")},
|
||||||
|
[]Card{g.pet("Wall", 20)}, // one clash kills the 2-power Lizard
|
||||||
|
)
|
||||||
|
bees := 0
|
||||||
|
for _, ev := range eventsOfType(res, "summon") {
|
||||||
|
if ev.Card != nil && ev.Card.Name == "Bee" {
|
||||||
|
bees++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bees == 0 {
|
||||||
|
t.Fatal("Lizard should add a Bee even when killed in one hit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Hurt effect marked SurviveOnly does NOT fire on a fatal hit: the Peacock's
|
||||||
|
// "if this pet hasn't fainted" apple is skipped when the clash kills it.
|
||||||
|
func TestPeacockNoEatOnFatalHit(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Peacock")},
|
||||||
|
[]Card{g.pet("Wall", 20)}, // one clash kills the 2-power Peacock
|
||||||
|
)
|
||||||
|
if len(eventsOfType(res, "eat")) != 0 {
|
||||||
|
t.Fatal("Peacock should not eat an apple when it faints")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBattleEqualPowerBothDie(t *testing.T) {
|
func TestBattleEqualPowerBothDie(t *testing.T) {
|
||||||
g, _, _ := testGame(t)
|
g, _, _ := testGame(t)
|
||||||
res := forceBattle(t, g,
|
res := forceBattle(t, g,
|
||||||
@@ -486,7 +518,8 @@ func TestDolphinThrowsThreeRocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Camel pushes an apple onto its own stack whenever it's hurt and survives.
|
// Camel pushes an apple onto its own stack whenever it's hurt — including the
|
||||||
|
// hit that finishes it, since Hurt now fires on faint.
|
||||||
func TestCamelHurtSummonsApple(t *testing.T) {
|
func TestCamelHurtSummonsApple(t *testing.T) {
|
||||||
g, _, _ := testGame(t)
|
g, _, _ := testGame(t)
|
||||||
res := forceBattle(t, g,
|
res := forceBattle(t, g,
|
||||||
@@ -494,10 +527,16 @@ func TestCamelHurtSummonsApple(t *testing.T) {
|
|||||||
[]Card{g.pet("Chip", 1), g.pet("Chip2", 3)},
|
[]Card{g.pet("Chip", 1), g.pet("Chip2", 3)},
|
||||||
)
|
)
|
||||||
// Clash 1: camel takes 1 (survives) → apple onto A's stack. Clash 2 vs
|
// Clash 1: camel takes 1 (survives) → apple onto A's stack. Clash 2 vs
|
||||||
// Chip2: both die. A reveals apple + Ally (3 power) and wins.
|
// Chip2: camel dies but its Hurt still fires → a second apple. A reveals
|
||||||
|
// both apples onto Ally and wins.
|
||||||
summons := eventsOfType(res, "summon")
|
summons := eventsOfType(res, "summon")
|
||||||
if len(summons) != 1 || summons[0].Seat != 0 || summons[0].Card.Food != FoodApple {
|
if len(summons) != 2 {
|
||||||
t.Fatalf("camel should summon one apple onto its own stack: %+v", summons)
|
t.Fatalf("camel should summon an apple each time it's hurt (twice): %+v", summons)
|
||||||
|
}
|
||||||
|
for _, s := range summons {
|
||||||
|
if s.Seat != 0 || s.Card.Food != FoodApple {
|
||||||
|
t.Fatalf("camel apples should land on its own stack: %+v", s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if res.WinnerSeat != 0 {
|
if res.WinnerSeat != 0 {
|
||||||
t.Fatalf("apple-buffed ally should win, got %d", res.WinnerSeat)
|
t.Fatalf("apple-buffed ally should win, got %d", res.WinnerSeat)
|
||||||
|
|||||||
+12
-3
@@ -258,6 +258,15 @@ type Effect struct {
|
|||||||
// effect to fire (Golden pack). It's auto-paid when affordable and the
|
// effect to fire (Golden pack). It's auto-paid when affordable and the
|
||||||
// effect is skipped otherwise — battles take no player input.
|
// effect is skipped otherwise — battles take no player input.
|
||||||
CostTrumpet int `json:"costTrumpet,omitempty"`
|
CostTrumpet int `json:"costTrumpet,omitempty"`
|
||||||
|
// SurviveOnly marks a Hurt effect that fires only when the pet lives through
|
||||||
|
// the hit. By default a Hurt effect still fires on a fatal hit (the Lizard
|
||||||
|
// drops its Bee even when killed); self-buffs that are pointless once the
|
||||||
|
// pet is gone (Peacock's apple, Gorilla's shield) opt out with this.
|
||||||
|
SurviveOnly bool `json:"surviveOnly,omitempty"`
|
||||||
|
// Posthumous marks a Play effect that still resolves even after its own pet
|
||||||
|
// has left play — so the Manatee still adds its Apples after rocking itself
|
||||||
|
// to death.
|
||||||
|
Posthumous bool `json:"posthumous,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// count normalizes the zero value to 1.
|
// count normalizes the zero value to 1.
|
||||||
@@ -361,7 +370,7 @@ var petTiers = [MaxRounds][]petTemplate{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Peacock", Power: 2, Suits: []Suit{SuitBlue, SuitRed},
|
Name: "Peacock", Power: 2, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionEatApple}},
|
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionEatApple, SurviveOnly: true}},
|
||||||
EffectText: "Hurt: if this pet hasn't fainted, it eats 1 Apple",
|
EffectText: "Hurt: if this pet hasn't fainted, it eats 1 Apple",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -500,7 +509,7 @@ var petTiers = [MaxRounds][]petTemplate{
|
|||||||
{ // Tier 6
|
{ // Tier 6
|
||||||
{
|
{
|
||||||
Name: "Gorilla", Power: 6, Suits: []Suit{SuitBlue, SuitYellow},
|
Name: "Gorilla", Power: 6, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionShieldSelf}},
|
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionShieldSelf, SurviveOnly: true}},
|
||||||
EffectText: "Hurt: the next time this pet is hit, prevent all damage",
|
EffectText: "Hurt: the next time this pet is hit, prevent all damage",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -727,7 +736,7 @@ var goldenPetTiers = [MaxRounds][]petTemplate{
|
|||||||
Name: "Manatee", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
Name: "Manatee", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
Effects: []Effect{
|
Effects: []Effect{
|
||||||
{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, Target: "self"},
|
{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, Target: "self"},
|
||||||
{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 4},
|
{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 4, Posthumous: true},
|
||||||
},
|
},
|
||||||
EffectText: "Play: throw 2 Rocks at itself, then add 4 Apples on top of your deck",
|
EffectText: "Play: throw 2 Rocks at itself, then add 4 Apples on top of your deck",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -136,6 +136,35 @@ func TestManateeSelfRock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Even when its own rocks are lethal, the Manatee still stacks all 4 apples —
|
||||||
|
// the summon is posthumous, so it fires after the Manatee faints.
|
||||||
|
func TestManateeSelfRockFatalStillAddsApples(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 2 } // 2 rocks = 4 damage, past its 3 power
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Manatee")},
|
||||||
|
[]Card{g.pet("Wall", 20)},
|
||||||
|
)
|
||||||
|
var self *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "rock" && ev.Target == 0 {
|
||||||
|
self = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self == nil || !self.TargetDied {
|
||||||
|
t.Fatalf("Manatee's own rocks should faint it: %+v", eventsOfType(res, "rock"))
|
||||||
|
}
|
||||||
|
apples := 0
|
||||||
|
for _, ev := range eventsOfType(res, "summon") {
|
||||||
|
if ev.Card != nil && ev.Card.Name == "Apple" {
|
||||||
|
apples++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apples != 4 {
|
||||||
|
t.Fatalf("Manatee should still add 4 apples after fainting, got %d", apples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Poison Dart Frog, once set aside, throws rocks each time a Bee is played.
|
// Poison Dart Frog, once set aside, throws rocks each time a Bee is played.
|
||||||
func TestPoisonDartFrogBeeRocks(t *testing.T) {
|
func TestPoisonDartFrogBeeRocks(t *testing.T) {
|
||||||
g, _, _ := testGame(t)
|
g, _, _ := testGame(t)
|
||||||
|
|||||||
@@ -206,6 +206,15 @@ export function CardView({
|
|||||||
onMouseLeave={preview ? undefined : () => setHover(null)}
|
onMouseLeave={preview ? undefined : () => setHover(null)}
|
||||||
>
|
>
|
||||||
{previewEl}
|
{previewEl}
|
||||||
|
{/* Power sits in a spiky burst badge in the top-left corner (pets only),
|
||||||
|
with any battle damage marker beside it. It's absolutely placed so it
|
||||||
|
doesn't eat into the room the name and ability text need. */}
|
||||||
|
{card.kind === 'pet' && (
|
||||||
|
<div className="card-combat">
|
||||||
|
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
|
||||||
|
{damage > 0 && !dead && <span className="card-damage">−{damage}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="card-top">
|
<div className="card-top">
|
||||||
<span className="card-tier">{card.tier ? `T${card.tier}` : '·'}</span>
|
<span className="card-tier">{card.tier ? `T${card.tier}` : '·'}</span>
|
||||||
{card.suit && <span className={`suit-dot suit-${card.suit}`} title={`${card.suit} suit`} />}
|
{card.suit && <span className={`suit-dot suit-${card.suit}`} title={`${card.suit} suit`} />}
|
||||||
@@ -214,27 +223,13 @@ export function CardView({
|
|||||||
{artFor(card.name)}
|
{artFor(card.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-name">{card.name}</div>
|
<div className="card-name">{card.name}</div>
|
||||||
{card.kind === 'pet' ? (
|
|
||||||
<>
|
|
||||||
{card.effectText && (
|
|
||||||
<div className="card-effect" ref={effectRef}>
|
|
||||||
{renderEffect(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-effect" ref={effectRef}>
|
<div className="card-effect" ref={effectRef}>
|
||||||
{card.effectText
|
{card.effectText
|
||||||
? renderEffect(card.effectText)
|
? renderEffect(card.effectText)
|
||||||
: card.food === 'apple'
|
: card.kind === 'food' && card.food === 'apple'
|
||||||
? '+1 power (this battle)'
|
? '+1 power (this battle)'
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{selected && <div className="card-check">✓</div>}
|
{selected && <div className="card-check">✓</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
wasMyTurn.current = myTurn
|
wasMyTurn.current = myTurn
|
||||||
}, [myTurn, view.pending, view.pendingReveal])
|
}, [myTurn, view.pending, view.pendingReveal])
|
||||||
const overPets = you.petCount > view.maxPets
|
const overPets = you.petCount > view.maxPets
|
||||||
|
|
||||||
|
// Show every coin you started the round with, fading the spent ones rather
|
||||||
|
// than dropping them. Coins only fall within a round, so the highest count
|
||||||
|
// seen this round is the starting purse. Reset when the round changes.
|
||||||
|
const purse = useRef({ round: view.round, max: you.coins })
|
||||||
|
if (purse.current.round !== view.round) {
|
||||||
|
purse.current = { round: view.round, max: you.coins }
|
||||||
|
}
|
||||||
|
purse.current.max = Math.max(purse.current.max, you.coins)
|
||||||
|
const totalCoins = purse.current.max
|
||||||
|
|
||||||
const deck = you.deck ?? []
|
const deck = you.deck ?? []
|
||||||
const opponent = view.players.find((p) => p.seat !== view.youSeat)
|
const opponent = view.players.find((p) => p.seat !== view.youSeat)
|
||||||
const pending = view.pending
|
const pending = view.pending
|
||||||
@@ -235,16 +246,24 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{turnBanner && <div className="turn-banner">Your turn!</div>}
|
{turnBanner && (
|
||||||
|
<div className="turn-banner">
|
||||||
|
<span className="turn-banner-pill">Your turn!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Coins as big golden discs above the buy row. */}
|
{/* Coins as big golden discs above the buy row. Spent coins stay put but
|
||||||
<div className="shop-coins" aria-label={`${you.coins} gold`}>
|
grey out, so you can see what you started the round with. */}
|
||||||
{Array.from({ length: Math.max(you.coins, 0) }, (_, i) => (
|
<div className="shop-coins" aria-label={`${you.coins} of ${totalCoins} gold`}>
|
||||||
<span key={i} className="coin-disc">
|
{totalCoins > 0 ? (
|
||||||
|
Array.from({ length: totalCoins }, (_, i) => (
|
||||||
|
<span key={i} className={`coin-disc ${i >= you.coins ? 'is-spent' : ''}`}>
|
||||||
🪙
|
🪙
|
||||||
</span>
|
</span>
|
||||||
))}
|
))
|
||||||
{you.coins === 0 && <span className="coin-empty muted">out of gold</span>}
|
) : (
|
||||||
|
<span className="coin-empty muted">out of gold</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shop row */}
|
{/* Shop row */}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useGame } from '../useGame'
|
import { useGame } from '../useGame'
|
||||||
import { useCatalog } from '../useCatalog'
|
import { useCatalog } from '../useCatalog'
|
||||||
|
import { CardView } from './CardView'
|
||||||
import type { Card, Session } from '../types'
|
import type { Card, Session } from '../types'
|
||||||
import { Lobby } from './Lobby'
|
import { Lobby } from './Lobby'
|
||||||
import { ShopPhase } from './ShopPhase'
|
import { ShopPhase } from './ShopPhase'
|
||||||
@@ -33,6 +35,10 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
|||||||
return { round: battleRound, step: next }
|
return { round: battleRound, step: next }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A shop-phase peek at an opponent's deck from the previous round's battle
|
||||||
|
// (its arranged lineup is already public). Anchored under the clicked button.
|
||||||
|
const [deckPeek, setDeckPeek] = useState<{ seat: number; rect: DOMRect } | null>(null)
|
||||||
|
|
||||||
// The battle outcome is known before the replay plays out, so we hold its
|
// The battle outcome is known before the replay plays out, so we hold its
|
||||||
// "result" log entry back: it's dropped from the persistent list during the
|
// "result" log entry back: it's dropped from the persistent list during the
|
||||||
// battle and appended as the final battle line only once the replay reaches
|
// battle and appended as the final battle line only once the replay reaches
|
||||||
@@ -145,6 +151,21 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
|||||||
{view.phase === 'shop' && p.seat !== view.youSeat && (
|
{view.phase === 'shop' && p.seat !== view.youSeat && (
|
||||||
<span className="chip">🪙 {p.coins}</span>
|
<span className="chip">🪙 {p.coins}</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Peek at the opponent's deck from last round's battle. */}
|
||||||
|
{view.phase === 'shop' &&
|
||||||
|
p.seat !== view.youSeat &&
|
||||||
|
(view.battle?.lineups?.[p.seat]?.length ?? 0) > 0 && (
|
||||||
|
<button
|
||||||
|
className={`chip chip-btn ${deckPeek?.seat === p.seat ? 'is-active' : ''}`}
|
||||||
|
title="See their deck from last round's battle"
|
||||||
|
onClick={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
setDeckPeek((cur) => (cur?.seat === p.seat ? null : { seat: p.seat, rect }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
👁 deck
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{(p.avocados ?? 0) > 0 && (
|
{(p.avocados ?? 0) > 0 && (
|
||||||
<span className="chip" title="Set-aside Avocados">🥑 {p.avocados}</span>
|
<span className="chip" title="Set-aside Avocados">🥑 {p.avocados}</span>
|
||||||
)}
|
)}
|
||||||
@@ -186,6 +207,35 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
|||||||
{view.debug && (
|
{view.debug && (
|
||||||
<DebugPanel canGrant={view.phase === 'shop'} pack={view.pack} send={send} />
|
<DebugPanel canGrant={view.phase === 'shop'} pack={view.pack} send={send} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{deckPeek &&
|
||||||
|
view.phase === 'shop' &&
|
||||||
|
(() => {
|
||||||
|
const lineup = view.battle?.lineups?.[deckPeek.seat] ?? []
|
||||||
|
if (!lineup.length) return null
|
||||||
|
const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent'
|
||||||
|
const left = Math.max(8, Math.min(deckPeek.rect.left, window.innerWidth - 380))
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div className="deck-peek-backdrop" onClick={() => setDeckPeek(null)} />
|
||||||
|
<div
|
||||||
|
className="deck-peek deck-peek-toolbar"
|
||||||
|
style={{ top: deckPeek.rect.bottom + 8, left }}
|
||||||
|
>
|
||||||
|
<div className="deck-peek-label">
|
||||||
|
{oppName}’s deck last round · {lineup.length} card
|
||||||
|
{lineup.length !== 1 ? 's' : ''} (top first)
|
||||||
|
</div>
|
||||||
|
<div className="deck-peek-cards">
|
||||||
|
{lineup.map((c, i) => (
|
||||||
|
<CardView key={c.id || i} card={c} size="sm" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-36
@@ -468,6 +468,28 @@ h3 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A chip that's actually a button (e.g. peek at the opponent's deck). */
|
||||||
|
.chip-btn {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-btn.is-active {
|
||||||
|
background: var(--gold);
|
||||||
|
color: var(--cocoa);
|
||||||
|
border-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
.conn-dot {
|
.conn-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -961,10 +983,26 @@ h3 {
|
|||||||
.card-top {
|
.card-top {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
/* The power badge now floats over the top-left corner, so tier + suit sit
|
||||||
|
together at the top-right. */
|
||||||
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
min-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Combat stats (power badge + battle damage marker) pinned to the top-left
|
||||||
|
corner. Absolute so they don't push the name and ability text down. */
|
||||||
|
.card-combat {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: 5px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-tier {
|
.card-tier {
|
||||||
@@ -1017,6 +1055,11 @@ h3 {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
color: var(--cocoa);
|
color: var(--cocoa);
|
||||||
|
/* Always center the name — including when a long name wraps to two lines, so
|
||||||
|
the second line stays centered instead of drifting off to one side. */
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-lg .card-name {
|
.card-lg .card-name {
|
||||||
@@ -1060,14 +1103,6 @@ h3 {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-bottom {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: auto;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Suit markers as glossy enamel dots. */
|
/* Suit markers as glossy enamel dots. */
|
||||||
.suit-dot {
|
.suit-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -1094,36 +1129,43 @@ h3 {
|
|||||||
background: radial-gradient(circle at 35% 28%, #ffdd6b, #e6ac1f);
|
background: radial-gradient(circle at 35% 28%, #ffdd6b, #e6ac1f);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The power badge is a stamped honey coin with a raised rim. */
|
/* The power badge is a fiery starburst — like a little explosion — carrying the
|
||||||
|
pet's attack. The spikes are cut with clip-path, so the drop shadow lives on
|
||||||
|
`filter` (a box-shadow would be clipped away with the corners). */
|
||||||
.card-power {
|
.card-power {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
background: radial-gradient(circle at 35% 28%, #ffcf6b, var(--coral-dark));
|
background: radial-gradient(circle at 38% 30%, #ffcf6b 4%, var(--coral) 42%, var(--coral-dark));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 0 1px 2px rgba(120, 45, 5, 0.55);
|
text-shadow: 0 1px 2px rgba(120, 45, 5, 0.7);
|
||||||
border-radius: 50%;
|
width: 32px;
|
||||||
width: 31px;
|
height: 32px;
|
||||||
height: 31px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 0.98rem;
|
font-size: 0.9rem;
|
||||||
/* The rim is drawn with an inset ring rather than a real border: a
|
clip-path: polygon(
|
||||||
border-radius:50% element with a semi-transparent border renders a
|
50% 0%, 61% 18%, 82% 12%, 78% 34%, 100% 43%, 83% 58%,
|
||||||
visible seam at its 6 o'clock point in Chromium (scale-invariant). */
|
93% 79%, 70% 76%, 62% 98%, 50% 82%, 38% 98%, 30% 76%,
|
||||||
box-shadow:
|
7% 79%, 17% 58%, 0% 43%, 22% 34%, 18% 12%, 39% 18%
|
||||||
inset 0 0 0 2px rgba(120, 45, 5, 0.35),
|
);
|
||||||
inset 0 1.5px 1px rgba(255, 255, 255, 0.55),
|
filter: drop-shadow(0 2px 2px rgba(20, 12, 4, 0.5));
|
||||||
inset 0 -2px 3px rgba(120, 45, 5, 0.4),
|
|
||||||
0 2px 3px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-power.is-buffed {
|
.card-power.is-buffed {
|
||||||
background: radial-gradient(circle at 35% 28%, #9ff0af, #1f9e55);
|
background: radial-gradient(circle at 38% 30%, #c9ffd4 4%, #4fd07f 42%, #1f9e55);
|
||||||
text-shadow: 0 1px 2px rgba(15, 60, 30, 0.55);
|
text-shadow: 0 1px 2px rgba(15, 60, 30, 0.7);
|
||||||
box-shadow:
|
filter: drop-shadow(0 0 5px rgba(80, 220, 120, 0.75));
|
||||||
inset 0 0 0 2px rgba(15, 80, 40, 0.4),
|
}
|
||||||
inset 0 1.5px 1px rgba(255, 255, 255, 0.55),
|
|
||||||
inset 0 -2px 3px rgba(15, 80, 40, 0.4),
|
.card-lg .card-power {
|
||||||
0 0 8px rgba(80, 220, 120, 0.5);
|
width: 37px;
|
||||||
|
height: 37px;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sm .card-power {
|
||||||
|
width: 27px;
|
||||||
|
height: 27px;
|
||||||
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-damage {
|
.card-damage {
|
||||||
@@ -1263,6 +1305,15 @@ h3 {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.4));
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.4));
|
||||||
animation: coin-pop 0.25s ease;
|
animation: coin-pop 0.25s ease;
|
||||||
|
transition: filter 200ms ease, opacity 200ms ease, transform 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A spent coin stays in place but greys out and fades. */
|
||||||
|
.coin-disc.is-spent {
|
||||||
|
filter: grayscale(1) brightness(0.7);
|
||||||
|
opacity: 0.4;
|
||||||
|
transform: scale(0.88);
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes coin-pop {
|
@keyframes coin-pop {
|
||||||
@@ -1285,10 +1336,23 @@ h3 {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light text on a golden rounded-rect plate, so the flourish reads as a solid
|
||||||
|
badge rather than bare text floating over the board. */
|
||||||
|
.turn-banner-pill {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: clamp(2.5rem, 9vw, 6rem);
|
font-size: clamp(2rem, 7vw, 4.5rem);
|
||||||
color: var(--gold);
|
color: #fff8e6;
|
||||||
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
|
background: linear-gradient(180deg, var(--gold), var(--gold-deep));
|
||||||
|
padding: 0.28em 0.8em;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.55);
|
||||||
|
text-shadow: 0 2px 5px rgba(120, 78, 8, 0.55);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 0 rgba(255, 255, 255, 0.55),
|
||||||
|
inset 0 -6px 14px rgba(150, 100, 20, 0.35),
|
||||||
|
0 14px 34px rgba(0, 0, 0, 0.5);
|
||||||
animation: turn-flash 1.4s ease forwards;
|
animation: turn-flash 1.4s ease forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1978,7 +2042,9 @@ h3 {
|
|||||||
|
|
||||||
.battle-unit .card {
|
.battle-unit .card {
|
||||||
width: 96px;
|
width: 96px;
|
||||||
height: 132px;
|
/* Taller than a shop card so the full ability text fits without shrinking to
|
||||||
|
an unreadable size during the battle. */
|
||||||
|
height: 162px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Battle cards are short, so give the ability text more of the card by
|
/* Battle cards are short, so give the ability text more of the card by
|
||||||
@@ -2389,7 +2455,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
.battle-unit .card {
|
.battle-unit .card {
|
||||||
width: 78px;
|
width: 78px;
|
||||||
height: 110px;
|
height: 134px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2540,3 +2606,11 @@ h3 {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
max-width: min(60vw, 560px);
|
max-width: min(60vw, 560px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Backdrop behind the toolbar deck-peek: any click outside dismisses it. Sits
|
||||||
|
below the popover (z 60) but above the board. */
|
||||||
|
.deck-peek-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 55;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user