From 235b1d9cc8cfa99b173bdb2f71f321c87c2c4280 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 11:00:11 -0400 Subject: [PATCH] Improve UI of food and set-aside cards. --- internal/game/battle.go | 173 ++++++++++++++++++++++------- internal/game/tier456_test.go | 87 ++++++++++++++- web/src/components/BattlePhase.tsx | 71 ++++++++---- web/src/components/EventLog.tsx | 2 + web/src/styles.css | 82 ++++++++------ web/src/types.ts | 2 + 6 files changed, 323 insertions(+), 94 deletions(-) diff --git a/internal/game/battle.go b/internal/game/battle.go index 739fb6a..8e9c58f 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -9,7 +9,27 @@ type BattleUnit struct { Foods []Card `json:"foods,omitempty"` Bonus int `json:"bonus"` // total power added by foods, eating, auras Damage int `json:"damage"` // damage markers accumulated this battle - Shield int `json:"shield"` // hits that will be fully prevented (Gorilla, Melon) + // Shields are this pet's own full-hit blocks, newest last. Each carries + // its source so the log can name it (Gorilla's innate block, or a Melon + // perk) and the client can drop the spent card. + Shields []shieldCharge +} + +// shieldCharge is one full-hit block a pet carries. source is the granting +// item's display name (e.g. "Melon"); an empty source is the pet's own innate +// shield (Gorilla). card, when set, is the food to remove from the play area +// once the charge is spent. +type shieldCharge struct { + source string + card *Card +} + +// shieldBlock describes a hit that a shield just absorbed, for the caller to +// narrate and clean up: source names the shield (empty = innate), and release +// is the field card to remove — a Melon food or a set-aside Turtle — or nil. +type shieldBlock struct { + source string + release *Card } func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus } @@ -83,6 +103,11 @@ type BattleEvent struct { // "steal": Seat's pet stole Count apples from Target's pet (Wolverine). // "eat": Seat's pet ate apples; Bonus is its new total. // "heal": Seat's pet healed; DamageAfter is its new damage total. + // "setaside": Card (a fainted pet) enters Seat's set-aside zone with a + // pending effect (Blowfish, Badger, Snake, Crocodile, Turtle, + // Turkey, Mammoth). + // "release": a set-aside Card left Seat's zone, its effect consumed + // (matched by Card.ID). Type string `json:"type"` // Seat/Target must NOT be omitempty: 0 is a valid seat (the first // player) and dropping it makes the client read sides[undefined]. @@ -125,6 +150,14 @@ type BattleResult struct { type setAsideRocks struct { dice int everyone bool // Badger: hits every active pet, the owner's own included + src Card // the fainted pet, for the set-aside display +} + +// lastPetVolley is Crocodile's set-aside: rocks that fire when the enemy plays +// the last pet in their deck. +type lastPetVolley struct { + dice int + src Card // the fainted pet, for the set-aside display } // battleSide is one seat's live state during the simulation. @@ -140,7 +173,8 @@ type battleSide struct { oneShotRocks []setAsideRocks // Badger/Blowfish: on next own pet play recurringRocks []int // Snake: on every own pet play - lastPetRocks []int // Crocodile: when the enemy plays their last pet + lastPetRocks []lastPetVolley // Crocodile: when the enemy plays their last pet + shieldCards []Card // Turtle set-aside cards, parallel to shields } // hasPetInStack reports whether any pet remains face-down in the stack. @@ -158,7 +192,8 @@ type queuedPlay struct { seat int unit *BattleUnit // the unit whose play queued this; nil for set-asides effect Effect - everyone bool // rock volley hits every active pet (Badger) + everyone bool // rock volley hits every active pet (Badger) + release *Card // set-aside card to release once this play resolves (Blowfish/Badger/Croc) } // effectCount resolves an effect's final count: base × Per statistic, @@ -266,24 +301,49 @@ func (g *Game) resolveBattle() { } // hitUnit applies one attack against a seat's pet: shields block the - // whole hit, Garlic shaves per-attack damage. Returns damage dealt and - // whether a shield blocked it. - hitUnit := func(seat, amount int) (dealt int, blocked bool) { + // whole hit, Garlic shaves per-attack damage. Returns the damage dealt and, + // when a shield absorbed the hit, a shieldBlock describing it (nil + // otherwise). A set-aside Turtle shield is spent before the pet's own + // shields (Melon/Gorilla) so the borrowed card clears the board first. + hitUnit := func(seat, amount int) (dealt int, block *shieldBlock) { u := sides[seat].unit if u == nil || amount <= 0 { - return 0, false - } - if u.Shield > 0 { - u.Shield-- - return 0, true + return 0, nil } if sides[seat].shields > 0 { sides[seat].shields-- - return 0, true + var card *Card + source := "" + if n := len(sides[seat].shieldCards); n > 0 { + c := sides[seat].shieldCards[n-1] + sides[seat].shieldCards = sides[seat].shieldCards[:n-1] + card = &c + source = c.Name + } + return 0, &shieldBlock{source: source, release: card} + } + if n := len(u.Shields); n > 0 { + sc := u.Shields[n-1] + u.Shields = u.Shields[:n-1] + return 0, &shieldBlock{source: sc.source, release: sc.card} } dealt = max(0, amount-u.prevention()) u.Damage += dealt - return dealt, false + return dealt, nil + } + + // emitShield narrates a blocked hit — naming the source rather than a bare + // "shield" — and removes the spent card (Melon food or set-aside Turtle) + // from the play area. + emitShield := func(seat int, petName string, block *shieldBlock) { + txt := fmt.Sprintf("%s blocks the hit.", petName) + if block.source != "" { + txt = fmt.Sprintf("%s blocks the hit with a %s.", petName, block.source) + } + emit(BattleEvent{Type: "shield", Seat: seat, Text: txt}) + if block.release != nil { + emit(BattleEvent{Type: "release", Seat: seat, Card: block.release}) + } } // faint fires the unit's faint effects (its own and its perk's) in @@ -296,6 +356,13 @@ func (g *Game) resolveBattle() { if isBee(u.Card) { s.beesFainted++ } + // setAside marks the fainted pet as kept beside the arena with a + // pending effect, so the client can show its card until it resolves. + setAside := func() { + c := u.Card + emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c, + Text: fmt.Sprintf("%s is set aside.", c.Name)}) + } for _, e := range u.effects() { if e.Trigger != TriggerFaint || !allowed(e, u) { continue @@ -319,17 +386,24 @@ func (g *Game) resolveBattle() { } case ActionDelayedRocks: s.oneShotRocks = append(s.oneShotRocks, - setAsideRocks{dice: e.count(), everyone: e.Target == "all"}) + setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card}) + setAside() case ActionRecurringRocks: s.recurringRocks = append(s.recurringRocks, e.count()) + setAside() case ActionEnemyLastPetRocks: - s.lastPetRocks = append(s.lastPetRocks, e.count()) + s.lastPetRocks = append(s.lastPetRocks, lastPetVolley{dice: e.count(), src: u.Card}) + setAside() case ActionShieldNext: s.shields += e.count() + s.shieldCards = append(s.shieldCards, u.Card) + setAside() case ActionBeeAura: s.beeBonus += e.count() + setAside() case ActionPetAura: s.petBonus += e.count() + setAside() } } // Enemy Faints triggers on surviving pets elsewhere (Hippo). @@ -373,7 +447,10 @@ func (g *Game) resolveBattle() { summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name)) } case ActionShieldSelf: - u.Shield += e.count() + // Gorilla's own hurt-triggered block: innate, no card to drop. + for range e.count() { + u.Shields = append(u.Shields, shieldCharge{}) + } } } } @@ -391,7 +468,7 @@ func (g *Game) resolveBattle() { faces[i] = g.rollRockDie() roll += faces[i] } - dealt, blocked := hitUnit(target, roll) + dealt, block := hitUnit(target, roll) died := !tu.Alive() thrower := pname(from) if su := sides[from].unit; su != nil { @@ -410,9 +487,8 @@ func (g *Game) resolveBattle() { Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces, DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt, }) - if blocked { - emit(BattleEvent{Type: "shield", Seat: target, - Text: fmt.Sprintf("%s blocks the rocks with a shield.", tu.Card.Name)}) + if block != nil { + emitShield(target, tu.Card.Name, block) } if died { faint(target, tu) @@ -483,26 +559,45 @@ func (g *Game) resolveBattle() { // Set-aside payouts fire before the new pet's own play // effects. for _, r := range s.oneShotRocks { + src := r.src plays = append(plays, queuedPlay{seat: seat, everyone: r.everyone, - effect: Effect{Action: ActionThrowRock, Count: r.dice}}) + effect: Effect{Action: ActionThrowRock, Count: r.dice}, release: &src}) } s.oneShotRocks = nil for _, dice := range s.recurringRocks { plays = append(plays, queuedPlay{seat: seat, effect: Effect{Action: ActionThrowRock, Count: dice}}) } - for _, e := range u.effects() { + // Walk the pet's own play effects, then its active perk's, so a + // play-time shield knows its source (an innate pet block vs a + // Melon perk that should be shown and later dropped). + addPlay := func(e Effect, perk *Card) { if e.Trigger != TriggerPlay { - continue + return } - // Shields apply the instant the pet enters play, ahead - // of any queued rocks (Melon). + // Shields apply the instant the pet enters play, ahead of + // any queued rocks (Melon). if e.Action == ActionShieldSelf { - u.Shield += e.count() - continue + for range e.count() { + ch := shieldCharge{} + if perk != nil { + food := *perk + ch = shieldCharge{source: perk.Name, card: &food} + } + u.Shields = append(u.Shields, ch) + } + return } plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e}) } + for _, e := range u.Card.Effects { + addPlay(e, nil) + } + if perk := u.activePerk(); perk != nil { + for _, e := range perk.Effects { + addPlay(e, perk) + } + } } } // Cross-side play triggers: Rhino rocks anyone who just played; @@ -524,9 +619,10 @@ func (g *Game) resolveBattle() { } } if !sides[seat].hasPetInStack() { - for _, dice := range os.lastPetRocks { + for _, v := range os.lastPetRocks { + src := v.src plays = append(plays, queuedPlay{seat: other, - effect: Effect{Action: ActionThrowRock, Count: dice}}) + effect: Effect{Action: ActionThrowRock, Count: v.dice}, release: &src}) } os.lastPetRocks = nil } @@ -582,6 +678,9 @@ func (g *Game) resolveBattle() { anyDeath = true } } + if q.release != nil { + emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release}) + } case ActionStripFoods: t := nextTarget(q.seat) if t < 0 { @@ -665,8 +764,8 @@ func (g *Game) resolveBattle() { // (the surrounding state is already per-seat). ua, ub := sides[0].unit, sides[1].unit powA, powB := ua.Power(), ub.Power() - dealtA, blockedA := hitUnit(0, powB) - dealtB, blockedB := hitUnit(1, powA) + dealtA, blockA := hitUnit(0, powB) + dealtB, blockB := hitUnit(1, powA) // Scorpion: a clash attack that hurts, KOs. if dealtA > 0 && ub.hasKnockout() { ua.Damage = max(ua.Damage, ua.Power()) @@ -706,15 +805,13 @@ func (g *Game) resolveBattle() { Died: []bool{!ua.Alive(), !ub.Alive()}, Text: clashTxt, }) - if blockedA { - emit(BattleEvent{Type: "shield", Seat: 0, - Text: fmt.Sprintf("%s blocks the hit with a shield.", ua.Card.Name)}) + if blockA != nil { + emitShield(0, ua.Card.Name, blockA) } - if blockedB { - emit(BattleEvent{Type: "shield", Seat: 1, - Text: fmt.Sprintf("%s blocks the hit with a shield.", ub.Card.Name)}) + if blockB != nil { + emitShield(1, ub.Card.Name, blockB) } - if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && !blockedA && !blockedB { + if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && blockA == nil && blockB == nil { break // stalemate: nothing can ever change } dealt := []int{dealtA, dealtB} diff --git a/internal/game/tier456_test.go b/internal/game/tier456_test.go index 86282ab..6fa09a9 100644 --- a/internal/game/tier456_test.go +++ b/internal/game/tier456_test.go @@ -1,6 +1,9 @@ package game -import "testing" +import ( + "strings" + "testing" +) // --- Tier 4 --- @@ -165,6 +168,42 @@ func TestBlowfishDelayedRocksEnemyOnly(t *testing.T) { } } +// A fainting Blowfish is set aside as a card (with its own ID), then released +// once the next pet plays and its volley resolves. +func TestBlowfishSetAsideThenRelease(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 1 } + blowfish := g.realPet(t, "Blowfish") + res := forceBattle(t, g, + []Card{blowfish, g.pet("Next", 2)}, + []Card{g.pet("Tank", 6)}, + ) + setasides := eventsOfType(res, "setaside") + if len(setasides) != 1 || setasides[0].Seat != 0 || setasides[0].Card == nil || + setasides[0].Card.ID != blowfish.ID { + t.Fatalf("blowfish should be set aside once for seat 0: %+v", setasides) + } + releases := eventsOfType(res, "release") + if len(releases) != 1 || releases[0].Seat != 0 || releases[0].Card == nil || + releases[0].Card.ID != blowfish.ID { + t.Fatalf("blowfish should be released once for seat 0: %+v", releases) + } + // setaside on faint, then release after the volley: setaside precedes the + // rock, which precedes the release. + idx := func(typ string) int { + for i, ev := range res.Events { + if ev.Type == typ { + return i + } + } + return -1 + } + if !(idx("setaside") < idx("rock") && idx("rock") < idx("release")) { + t.Fatalf("expected setaside < rock < release, got %d < %d < %d", + idx("setaside"), idx("rock"), idx("release")) + } +} + // Skunk strips the enemy pet's foods; a wounded veteran can faint from the // power loss. func TestSkunkStripCanKill(t *testing.T) { @@ -441,6 +480,52 @@ func TestMelonBlocksFirstHit(t *testing.T) { } } +// A used Melon names itself in the log and is released from the play area so +// the fan clears. +func TestMelonBlockNamesSourceAndClears(t *testing.T) { + g, _, _ := testGame(t) + melon := g.realFood(t, "Melon") + res := forceBattle(t, g, + []Card{melon, g.pet("Holder", 3)}, + []Card{g.pet("Big", 5)}, + ) + shields := eventsOfType(res, "shield") + if len(shields) != 1 || !strings.Contains(shields[0].Text, "Melon") || + strings.Contains(shields[0].Text, "shield") { + t.Fatalf("shield line should name the Melon, not say 'shield': %q", shields[0].Text) + } + releases := eventsOfType(res, "release") + if len(releases) != 1 || releases[0].Card == nil || releases[0].Card.ID != melon.ID { + t.Fatalf("the spent Melon should be released from the play area: %+v", releases) + } +} + +// With both a set-aside Turtle and a Melon protecting the same pet, the +// borrowed Turtle shield is spent first so its card clears the board. +func TestTurtleShieldSpentBeforeMelon(t *testing.T) { + g, _, _ := testGame(t) + turtle := g.realPet(t, "Turtle") // power 2 + res := forceBattle(t, g, + []Card{turtle, g.realFood(t, "Melon"), g.pet("Holder", 3)}, + []Card{g.pet("Big", 5)}, + ) + // Turtle (2) dies to Big (5), setting aside a shield; Big keeps 2 damage. + // Holder plays with Melon (own shield). The next hit is absorbed by the + // Turtle set-aside first, releasing the Turtle; Holder then downs Big and + // its Melon shield is never spent. + releases := eventsOfType(res, "release") + if len(releases) != 1 || releases[0].Card == nil || releases[0].Card.ID != turtle.ID { + t.Fatalf("the Turtle shield should be spent (and released) before the Melon: %+v", releases) + } + shields := eventsOfType(res, "shield") + if len(shields) != 1 || !strings.Contains(shields[0].Text, "Turtle") { + t.Fatalf("the block should be credited to the Turtle: %+v", shields) + } + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win with the Melon still in reserve, got %d", res.WinnerSeat) + } +} + // Leopard throws rocks equal to its power, apples included. func TestLeopardRocksEqualPower(t *testing.T) { g, _, _ := testGame(t) diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 9866eeb..0182865 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -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 = { 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) { )} + {rockDice && lastEvent?.seat === seat && ( + // The dice roll sits directly under the throwing side's deck. +
+ +
+ )} ) - const foodsEl = ( -
- {s.pending.map((f) => ( - - {artFor(f.name)} - + // Set-aside pets (Blowfish, Badger, …) sit in a row above the active pet + // until their pending effect resolves. + const setAsideEl = s.setAside.length > 0 && ( +
+ {s.setAside.map((c) => ( + + ))} +
+ ) + + // 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 && ( +
+ {foods.map((f, i) => ( +
+ +
))}
) const unitEl = (
+ {setAsideEl} + {foodFanEl} {s.unit && (
- {s.unit.foods.length > 0 && ( -
- {s.unit.foods.map((f) => ( - - {artFor(f.name)} - - ))} -
- )} {pop && (
{pop} @@ -353,13 +381,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) { return dir === 'left' ? (
{stackEl} - {foodsEl} {unitEl}
) : (
{unitEl} - {foodsEl} {stackEl}
) @@ -423,7 +449,6 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
{renderSide(oppSeat, 'right')} - {rockDice && }
{peek && diff --git a/web/src/components/EventLog.tsx b/web/src/components/EventLog.tsx index ea2ef47..a91df4d 100644 --- a/web/src/components/EventLog.tsx +++ b/web/src/components/EventLog.tsx @@ -22,6 +22,8 @@ const BATTLE_ICONS: Record = { steal: '🍎', eat: '🍎', heal: '💚', + setaside: '🃏', + release: '↩️', } // battleLogLines turns the battle events revealed up to `step` into readable diff --git a/web/src/styles.css b/web/src/styles.css index 2eac684..1aee67e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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 { diff --git a/web/src/types.ts b/web/src/types.ts index 257090b..f0ba230 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -50,6 +50,8 @@ export interface BattleEvent { | 'steal' | 'eat' | 'heal' + | 'setaside' + | 'release' seat?: number target?: number card?: Card