Move the UI to be vertical.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
||||
import { CardView } from './CardView'
|
||||
import { useCardAnimations } from '../anim'
|
||||
@@ -9,31 +9,21 @@ interface Props {
|
||||
send: (msg: ClientMessage) => void
|
||||
}
|
||||
|
||||
// ArrangePhase lets the player order their deck for battle. Rightmost card
|
||||
// fights first; food cards buff the next pet to their left... i.e. foods
|
||||
// apply to the next pet later in the play order. Internally `order` stays in
|
||||
// play order (index 0 fights first) to match the backend; we only reverse for
|
||||
// display. Drag cards or use the arrow buttons to reorder, then lock in.
|
||||
// ArrangePhase lets the player order their deck for battle. The list runs top to
|
||||
// bottom in play order: the topmost card fights first, and food cards buff the
|
||||
// next pet below them. `order` stays in play order (index 0 fights first) to
|
||||
// match the backend. Drag cards or use the arrow buttons to reorder, then lock
|
||||
// in.
|
||||
export function ArrangePhase({ view, you, send }: Props) {
|
||||
const [order, setOrder] = useState<Card[]>(you.deck ?? [])
|
||||
const dragIndex = useRef<number | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const locked = you.ready
|
||||
|
||||
// Connector arrows drawn in the gaps between wrapped rows. The battle line
|
||||
// reads right-to-left (card 0 fights first, top-right) and wraps downward, so
|
||||
// when the line spills onto a new row we draw a curve from the leftmost card
|
||||
// of a row to the rightmost card of the row below it — i.e. following the
|
||||
// play order across the wrap, arrowhead landing on the next pet to fight.
|
||||
const trayRef = useRef<HTMLDivElement>(null)
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
// The arrow buttons reorder `order` and the cards glide to their new slot.
|
||||
// Drag-and-drop reorders instantly instead: a drag fires continuously, so
|
||||
// animating every crossing looks frantic — we suppress it while dragging.
|
||||
const arrangeAnim = useCardAnimations(order, (c) => c.id, !dragging)
|
||||
const [connectors, setConnectors] = useState<string[]>([])
|
||||
const [svgSize, setSvgSize] = useState({ w: 0, h: 0 })
|
||||
|
||||
// Resync only if the deck's actual contents changed — every broadcast
|
||||
// creates a fresh array, and blindly resetting would wipe an in-progress
|
||||
@@ -49,100 +39,6 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
})
|
||||
}, [you.deck])
|
||||
|
||||
// Measure the laid-out cards and recompute the inter-row connector paths.
|
||||
// The arrows connect fixed slot positions, not specific cards: reordering
|
||||
// swaps which card sits in a slot but never moves the slots, so we only
|
||||
// remeasure when the card count changes or the tray resizes (which is what
|
||||
// actually moves the wrap points). Recomputing on every reorder would also
|
||||
// read cards mid-glide and make the arrows jump around.
|
||||
useLayoutEffect(() => {
|
||||
const tray = trayRef.current
|
||||
if (!tray) return
|
||||
|
||||
const measure = () => {
|
||||
const trayBox = tray.getBoundingClientRect()
|
||||
setSvgSize({ w: trayBox.width, h: trayBox.height })
|
||||
|
||||
// Card boxes in play order. The `.card` gives the art rect; the wrapper
|
||||
// also spans the reorder buttons, so its bottom marks the true row floor.
|
||||
const boxes: {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
wrapBottom: number
|
||||
}[] = []
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const wrap = cardRefs.current[i]
|
||||
if (!wrap) continue
|
||||
const cardEl = (wrap.querySelector('.card') as HTMLElement | null) ?? wrap
|
||||
const cb = cardEl.getBoundingClientRect()
|
||||
const wb = wrap.getBoundingClientRect()
|
||||
boxes.push({
|
||||
left: cb.left - trayBox.left,
|
||||
right: cb.right - trayBox.left,
|
||||
top: cb.top - trayBox.top,
|
||||
bottom: cb.bottom - trayBox.top,
|
||||
wrapBottom: wb.bottom - trayBox.top,
|
||||
})
|
||||
}
|
||||
|
||||
// Cards run in play order and the RTL wrap makes each visual row a
|
||||
// contiguous run (rightmost = earliest to play). Group by shared top edge.
|
||||
const rows: number[][] = []
|
||||
boxes.forEach((b, i) => {
|
||||
const row = rows[rows.length - 1]
|
||||
if (row && Math.abs(boxes[row[0]].top - b.top) < 24) row.push(i)
|
||||
else rows.push([i])
|
||||
})
|
||||
|
||||
// One connector per row break, running flat along the band between rows.
|
||||
// It comes out the right side (mid-height) of the lower row's rightmost
|
||||
// card — the next pet to play — sweeps across, and points into the left
|
||||
// side (mid-height) of the row above's leftmost card, so the wrap reads as
|
||||
// a return to where the line began. OUT is how far the risers sit outside
|
||||
// the cards — kept generous so the arrowhead has a clean straight run to
|
||||
// render on rather than being crammed against the card edge.
|
||||
const OUT = 26
|
||||
const conns: string[] = []
|
||||
for (let r = 0; r < rows.length - 1; r++) {
|
||||
const upper = rows[r]
|
||||
const lower = rows[r + 1]
|
||||
const head = boxes[upper[upper.length - 1]] // upper row, leftmost card
|
||||
const tail = boxes[lower[0]] // lower row, rightmost card
|
||||
const upperFloor = Math.max(...upper.map((i) => boxes[i].wrapBottom))
|
||||
const lowerTop = Math.min(...lower.map((i) => boxes[i].top))
|
||||
const flatY = (upperFloor + lowerTop) / 2
|
||||
const headMidY = (head.top + head.bottom) / 2
|
||||
const tailMidY = (tail.top + tail.bottom) / 2
|
||||
const xL = head.left - OUT
|
||||
const xR = tail.right + OUT
|
||||
const rad = Math.min(10, OUT, (tailMidY - flatY) / 2, (flatY - headMidY) / 2)
|
||||
conns.push(
|
||||
[
|
||||
`M ${tail.right},${tailMidY}`,
|
||||
`L ${xR - rad},${tailMidY}`,
|
||||
`Q ${xR},${tailMidY} ${xR},${tailMidY - rad}`,
|
||||
`L ${xR},${flatY + rad}`,
|
||||
`Q ${xR},${flatY} ${xR - rad},${flatY}`,
|
||||
`L ${xL + rad},${flatY}`,
|
||||
`Q ${xL},${flatY} ${xL},${flatY - rad}`,
|
||||
`L ${xL},${headMidY + rad}`,
|
||||
`Q ${xL},${headMidY} ${xL + rad},${headMidY}`,
|
||||
`L ${head.left},${headMidY}`,
|
||||
].join(' '),
|
||||
)
|
||||
}
|
||||
setConnectors(conns)
|
||||
}
|
||||
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(tray)
|
||||
return () => ro.disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [order.length])
|
||||
|
||||
function move(from: number, to: number) {
|
||||
if (to < 0 || to >= order.length) return
|
||||
setOrder((o) => {
|
||||
@@ -153,7 +49,8 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
})
|
||||
}
|
||||
|
||||
// Which pets do the foods land on? Compute buff per card for preview.
|
||||
// Which pets do the foods land on? Foods buff the next pet later in play order
|
||||
// (i.e. the next pet below them in the list). Compute buff per card for preview.
|
||||
const bonuses = new Map<string, number>()
|
||||
{
|
||||
let pendingApples = 0
|
||||
@@ -166,6 +63,8 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Foods at the very end of play order (bottom of the list) have no pet after
|
||||
// them, so their buff is wasted.
|
||||
const trailingFoods = (() => {
|
||||
let n = 0
|
||||
for (let i = order.length - 1; i >= 0 && order[i].kind === 'food'; i--) n++
|
||||
@@ -191,26 +90,17 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
<span className="status-hot">Arrange your battle line</span>
|
||||
</div>
|
||||
<p className="hint">
|
||||
The <strong>rightmost</strong> card fights first. Food cards power up the
|
||||
next pet to their <strong>left</strong>.
|
||||
The <strong>topmost</strong> card fights first. Food cards power up the
|
||||
next pet <strong>below</strong> them.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="arrange-row"
|
||||
ref={(el) => {
|
||||
trayRef.current = el
|
||||
arrangeAnim.containerRef.current = el
|
||||
}}
|
||||
>
|
||||
<div className="arrange-marker">⚔️ first</div>
|
||||
<div className="arrange-col" ref={arrangeAnim.containerRef}>
|
||||
<div className="arrange-marker">⚔️ first to fight</div>
|
||||
{order.map((c, i) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="arrange-card"
|
||||
data-flip-key={c.id}
|
||||
ref={(el) => {
|
||||
cardRefs.current[i] = el
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => {
|
||||
dragIndex.current = i
|
||||
@@ -228,57 +118,33 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
setDragging(false)
|
||||
}}
|
||||
>
|
||||
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
|
||||
<div className="arrange-arrows">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={i === order.length - 1}
|
||||
onClick={() => move(i, i + 1)}
|
||||
aria-label="move left"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, i - 1)}
|
||||
aria-label="move right"
|
||||
aria-label="move up (earlier)"
|
||||
>
|
||||
▶
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={i === order.length - 1}
|
||||
onClick={() => move(i, i + 1)}
|
||||
aria-label="move down (later)"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
|
||||
</div>
|
||||
))}
|
||||
<svg
|
||||
className="arrange-connectors"
|
||||
width={svgSize.w}
|
||||
height={svgSize.h}
|
||||
viewBox={`0 0 ${svgSize.w} ${svgSize.h}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrange-arrowhead"
|
||||
markerWidth="12"
|
||||
markerHeight="12"
|
||||
refX="9"
|
||||
refY="5"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path className="arrange-arrowhead-shape" d="M0,0 L10,5 L0,10 Z" />
|
||||
</marker>
|
||||
</defs>
|
||||
{connectors.map((d, i) => (
|
||||
<path key={i} className="arrange-connector" d={d} markerEnd="url(#arrange-arrowhead)" />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{trailingFoods > 0 && (
|
||||
<p className="hint warn-text">
|
||||
⚠️ {trailingFoods} food card{trailingFoods > 1 ? 's' : ''} on the far
|
||||
left will be wasted!
|
||||
⚠️ {trailingFoods} food card{trailingFoods > 1 ? 's' : ''} at the
|
||||
bottom will be wasted!
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
// (and thus overflow-y) to auto — so an absolutely-positioned popover gets
|
||||
// clipped to the battlefield. We anchor it to the hovered stack's viewport
|
||||
// rect and portal it to <body> so it floats over the whole window instead.
|
||||
const [peek, setPeek] = useState<{ seat: number; dir: 'left' | 'right'; rect: DOMRect } | null>(
|
||||
const [peek, setPeek] = useState<{ seat: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>(
|
||||
null,
|
||||
)
|
||||
const lineups = battle.lineups
|
||||
@@ -319,7 +319,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
? lastEvent.dice
|
||||
: null
|
||||
|
||||
function renderSide(seat: number, dir: 'left' | 'right') {
|
||||
// pos is the half this seat occupies: 'top' = opponent, 'bottom' = you. The
|
||||
// two pets meet at a horizontal clash line. Each half is a row —
|
||||
// [set-aside | pet | apples] — with the deck under the pet (bottom) or over
|
||||
// it (top). Set-aside stays on the left and apples on the right for both.
|
||||
function renderSide(seat: number, pos: 'top' | 'bottom') {
|
||||
const s = sides[seat]
|
||||
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
|
||||
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
|
||||
@@ -336,7 +340,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
(lastEvent.type === 'reveal' && lastEvent.card?.kind === 'food'))
|
||||
? lastEvent.card?.id
|
||||
: null
|
||||
// A pet just set aside rises up from the arena into the set-aside row.
|
||||
// A pet just set aside slides into the set-aside row beside the arena.
|
||||
const newSetAsideId =
|
||||
!done && lastEvent?.type === 'setaside' && lastEvent.seat === seat
|
||||
? lastEvent.card?.id
|
||||
@@ -346,11 +350,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
|
||||
const lineup = lineups?.[seat] ?? []
|
||||
const stackEl = (
|
||||
<div className={`battle-deck battle-deck-${pos}`}>
|
||||
<div
|
||||
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
|
||||
onMouseEnter={
|
||||
lineup.length
|
||||
? (e) => setPeek({ seat, dir, rect: e.currentTarget.getBoundingClientRect() })
|
||||
? (e) => setPeek({ seat, pos, rect: e.currentTarget.getBoundingClientRect() })
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))}
|
||||
@@ -364,10 +369,10 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
<div className="card-slot-empty stack-empty" />
|
||||
)}
|
||||
{summoning && lastEvent?.card && (
|
||||
// Fly the spawned apple/bee out from where the pet stood (the unit
|
||||
// zone sits on the inner edge) onto the top of the deck, so it reads
|
||||
// as coming from the pet that just fainted.
|
||||
<div className={`summon-pop ${dir === 'left' ? 'summon-from-right' : 'summon-from-left'}`}>
|
||||
// Fly the spawned apple/bee out from where the pet stood (toward the
|
||||
// clash line) onto the deck, so it reads as coming from the pet that
|
||||
// just fainted.
|
||||
<div className={`summon-pop summon-to-${pos}`}>
|
||||
<CardView card={lastEvent.card} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
@@ -377,16 +382,17 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
</div>
|
||||
)}
|
||||
{rockDice && lastEvent?.seat === seat && (
|
||||
// The dice roll sits directly under the throwing side's deck.
|
||||
// The dice roll sits beside the throwing side's deck.
|
||||
<div className="stack-dice">
|
||||
<DiceRoll key={step} dice={rockDice} side={dir} />
|
||||
<DiceRoll key={step} dice={rockDice} side={pos} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Set-aside pets (Blowfish, Badger, …) sit in a row above the active pet
|
||||
// until their pending effect resolves.
|
||||
// Set-aside pets (Blowfish, Badger, …) sit in a row to the left of the
|
||||
// active pet until their pending effect resolves.
|
||||
const setAsideEl = s.setAside.length > 0 && (
|
||||
<div className="setaside-row">
|
||||
{s.setAside.map((c) => (
|
||||
@@ -403,8 +409,8 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
)
|
||||
|
||||
// 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.
|
||||
// next) fan out to the right of it: a horizontal row overlapping ~¾, so
|
||||
// only each card's left edge shows — like cards laid out on a table.
|
||||
const foods = s.unit ? s.unit.foods : s.pending
|
||||
const foodFanEl = foods.length > 0 && (
|
||||
<div className="food-fan">
|
||||
@@ -412,7 +418,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
<div
|
||||
key={f.id}
|
||||
className={`food-fan-card ${s.leaving.includes(f.id) ? 'is-leaving' : ''} ${
|
||||
f.id === newFoodId ? `food-in food-in-${dir}` : ''
|
||||
f.id === newFoodId ? `food-in food-in-${pos}` : ''
|
||||
}`}
|
||||
style={{ zIndex: i + 1 }}
|
||||
>
|
||||
@@ -431,9 +437,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
key={`${s.unit.card.id}-${clashing || rockVictim ? step : 'idle'}`}
|
||||
className={[
|
||||
'battle-unit',
|
||||
clashing || clashDying ? `clash-${dir}` : '',
|
||||
clashing || clashDying ? `clash-${pos}` : '',
|
||||
s.unit.dying ? 'unit-dying' : '',
|
||||
revealing ? `unit-reveal unit-reveal-${dir}` : '',
|
||||
revealing ? `unit-reveal unit-reveal-${pos}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
@@ -454,15 +460,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
</div>
|
||||
)
|
||||
|
||||
return dir === 'left' ? (
|
||||
<div className="battle-side">
|
||||
return (
|
||||
<div className={`battle-side battle-side-${pos}`}>
|
||||
{pos === 'top' ? (
|
||||
<>
|
||||
{stackEl}
|
||||
{unitEl}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="battle-side">
|
||||
<>
|
||||
{unitEl}
|
||||
{stackEl}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -520,35 +530,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="battlefield">
|
||||
{renderSide(youSeat, 'left')}
|
||||
{renderSide(oppSeat, 'top')}
|
||||
<div className="battle-center" aria-hidden>
|
||||
<span className="battle-center-bolt">⚡</span>
|
||||
</div>
|
||||
{renderSide(oppSeat, 'right')}
|
||||
{renderSide(youSeat, 'bottom')}
|
||||
</div>
|
||||
|
||||
{peek &&
|
||||
(() => {
|
||||
const lineup = lineups?.[peek.seat] ?? []
|
||||
if (!lineup.length) return null
|
||||
// Your deck sits at the bottom of the board, so float the peek above
|
||||
// it; the rival's sits at the top, so float it below.
|
||||
const centerX = peek.rect.left + peek.rect.width / 2
|
||||
const style: React.CSSProperties = {
|
||||
bottom: window.innerHeight - peek.rect.top + 8,
|
||||
...(peek.dir === 'left'
|
||||
? { left: peek.rect.left }
|
||||
: { right: window.innerWidth - peek.rect.right }),
|
||||
left: centerX,
|
||||
transform: 'translateX(-50%)',
|
||||
...(peek.pos === 'bottom'
|
||||
? { bottom: window.innerHeight - peek.rect.top + 8 }
|
||||
: { top: peek.rect.bottom + 8 }),
|
||||
}
|
||||
// Your deck sits on the left of the board and fights from its right
|
||||
// edge; the rival's sits on the right and fights from its left edge.
|
||||
// Order each peek to match, so "first" always points toward the clash.
|
||||
const mine = peek.seat === youSeat
|
||||
const firstSide = mine ? 'right' : 'left'
|
||||
// Each player's first pet is the one nearest the clash line; show the
|
||||
// lineup first-to-last, left to right, for both.
|
||||
return createPortal(
|
||||
<div className={`deck-peek deck-peek-${peek.dir}`} style={style}>
|
||||
<div className={`deck-peek deck-peek-${peek.pos}`} style={style}>
|
||||
<div className="deck-peek-label">
|
||||
{mine ? 'Your' : 'Opponent’s'} deck · {lineup.length} card
|
||||
{lineup.length !== 1 ? 's' : ''} (first on the {firstSide})
|
||||
{lineup.length !== 1 ? 's' : ''} (first on the left)
|
||||
</div>
|
||||
<div className={`deck-peek-cards first-${firstSide}`}>
|
||||
<div className="deck-peek-cards first-left">
|
||||
<div className="deck-peek-first">⚔️ first</div>
|
||||
{lineup.map((c, i) => (
|
||||
<CardView key={c.id || i} card={c} size="sm" />
|
||||
|
||||
@@ -12,7 +12,7 @@ const TICK_MS = 70
|
||||
// on the values the server actually rolled. Faces carry rock icons (0, 1, or
|
||||
// 2 rocks) — the same die the game uses, not a pip D6. Mount it with a `key`
|
||||
// that changes per rock event so the scramble replays every time.
|
||||
export function DiceRoll({ dice, side }: { dice: number[]; side: 'left' | 'right' }) {
|
||||
export function DiceRoll({ dice, side }: { dice: number[]; side: 'top' | 'bottom' }) {
|
||||
const [display, setDisplay] = useState<number[]>(dice)
|
||||
const [settled, setSettled] = useState(false)
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
// (its arranged lineup is already public). Shown as a centered modal.
|
||||
const [deckPeek, setDeckPeek] = useState<{ seat: number } | null>(null)
|
||||
|
||||
// On narrow screens the event log rides in a drawer that slides over the play
|
||||
// field; on wide screens it stays docked as a right column (CSS-driven) and
|
||||
// this open flag is simply ignored.
|
||||
const [logOpen, setLogOpen] = useState(false)
|
||||
|
||||
// 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
|
||||
// battle and appended as the final battle line only once the replay reaches
|
||||
@@ -87,7 +92,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
if (view) {
|
||||
view.shopRow?.forEach(add)
|
||||
view.players?.forEach((p) => p.deck?.forEach(add))
|
||||
view.battle?.lineups?.forEach((line) => line.forEach(add))
|
||||
view.battle?.lineups?.forEach((line) => line?.forEach(add))
|
||||
view.battle?.events?.forEach((ev) => add(ev.card))
|
||||
}
|
||||
return map
|
||||
@@ -190,12 +195,26 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
|
||||
</main>
|
||||
{view.phase !== 'lobby' && (
|
||||
<>
|
||||
{/* Floating toggle — only shown on narrow screens (CSS). */}
|
||||
<button
|
||||
className="log-fab"
|
||||
onClick={() => setLogOpen((o) => !o)}
|
||||
aria-label="Toggle event log"
|
||||
title="Event log"
|
||||
>
|
||||
📜
|
||||
</button>
|
||||
{logOpen && <div className="log-backdrop" onClick={() => setLogOpen(false)} />}
|
||||
<div className={`event-log-dock ${logOpen ? 'is-open' : ''}`}>
|
||||
<EventLog
|
||||
entries={entries}
|
||||
youSeat={view.youSeat}
|
||||
battleLines={battleLines}
|
||||
cardLookup={cardLookup}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+216
-172
@@ -531,10 +531,13 @@ h3 {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* A centred portrait column — the app is laid out for a tall, narrow play
|
||||
field, so it stays compact even on a wide desktop (the event log docks
|
||||
alongside it). */
|
||||
.table-main {
|
||||
flex: 1;
|
||||
padding: 26px 20px 48px;
|
||||
max-width: 1100px;
|
||||
padding: 22px 16px 48px;
|
||||
max-width: 620px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -668,24 +671,94 @@ h3 {
|
||||
box-shadow: inset 0 0 0 1px rgba(246, 201, 78, 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.table-body {
|
||||
flex-direction: column;
|
||||
/* On wide screens the dock wrapper is transparent to layout, so the log docks
|
||||
as a right column exactly as its own rules describe. */
|
||||
.event-log-dock {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* The floating log button and drawer backdrop only exist on narrow screens. */
|
||||
.log-fab,
|
||||
.log-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Narrow screens: the event log becomes a drawer that slides in from the right
|
||||
over the play field, opened by a floating button. */
|
||||
@media (max-width: 1000px) {
|
||||
.event-log-dock {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(340px, 88vw);
|
||||
z-index: 80;
|
||||
transform: translateX(100%);
|
||||
transition: transform 240ms ease;
|
||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.event-log {
|
||||
|
||||
.event-log-dock.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.event-log-dock .event-log {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
position: static;
|
||||
max-height: 220px;
|
||||
border-left: none;
|
||||
border-top: 3px solid rgba(0, 0, 0, 0.35);
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.event-log.is-collapsed {
|
||||
|
||||
.event-log-dock .event-log.is-collapsed {
|
||||
width: 100%;
|
||||
}
|
||||
.event-log.is-collapsed .event-log-head {
|
||||
|
||||
.event-log-dock .event-log.is-collapsed .event-log-head {
|
||||
writing-mode: horizontal-tb;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.log-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 75;
|
||||
background: rgba(10, 20, 14, 0.5);
|
||||
backdrop-filter: blur(1.5px);
|
||||
animation: result-in 200ms ease;
|
||||
}
|
||||
|
||||
.log-fab {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
z-index: 70;
|
||||
font-size: 1.4rem;
|
||||
background: linear-gradient(180deg, var(--wood-light), var(--wood));
|
||||
color: var(--cream);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
||||
0 4px 0 var(--wood-dark),
|
||||
0 6px 14px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.log-fab:active {
|
||||
transform: translateY(3px);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
0 1px 0 var(--wood-dark),
|
||||
0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- lobby ---------- */
|
||||
@@ -1567,79 +1640,42 @@ h3 {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.arrange-row {
|
||||
/* A vertical battle line laid into a felt tray: card 0 (fights first) sits at
|
||||
the top and later pets flow downward. */
|
||||
.arrange-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
/* Battle line reads right-to-left, wrapping downward: card 0 (fights first)
|
||||
sits top-right and later pets flow left, then onto rows below. The wide
|
||||
row-gap leaves a band for the wrap-around connector arrows. */
|
||||
direction: rtl;
|
||||
column-gap: 14px;
|
||||
row-gap: 56px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 120% at 50% 0%, rgba(255, 255, 255, 0.04), transparent 70%),
|
||||
radial-gradient(ellipse 120% 80% at 50% 0%, rgba(255, 255, 255, 0.04), transparent 70%),
|
||||
rgba(0, 0, 0, 0.24);
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
border-radius: 18px;
|
||||
/* Extra right padding reserves the strip the "first" marker sits in, so its
|
||||
width doesn't shove the top row left of the wrapped rows below it. */
|
||||
padding: 20px 52px 20px 18px;
|
||||
padding: 16px 18px 20px;
|
||||
min-height: 224px;
|
||||
box-shadow: var(--tray-inset);
|
||||
}
|
||||
|
||||
/* Pinned out of flow to the reserved strip beside the first (rightmost) card,
|
||||
vertically centred on the top row, so it never affects card alignment. */
|
||||
/* The "first to fight" label sits at the top of the column, above the first
|
||||
card. It's a static first child (no data-flip-key), so the FLIP animator
|
||||
ignores it. */
|
||||
.arrange-marker {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 16px;
|
||||
height: 148px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
direction: ltr;
|
||||
font-family: var(--font-display);
|
||||
color: var(--gold);
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.85;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
/* SVG overlay for the wrap-around connector arrows. Absolute so it sits out of
|
||||
the flex flow; pointer-events off so it never blocks dragging. overflow is
|
||||
visible because the curve control points can bow just past the tray edge. */
|
||||
.arrange-connectors {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.arrange-connector {
|
||||
fill: none;
|
||||
stroke: rgba(190, 194, 200, 0.55);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.arrange-arrowhead-shape {
|
||||
fill: rgba(190, 194, 200, 0.55);
|
||||
}
|
||||
|
||||
/* Each row: reorder arrows on the left, the card on the right. */
|
||||
.arrange-card {
|
||||
direction: ltr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
@@ -1649,7 +1685,8 @@ h3 {
|
||||
|
||||
.arrange-arrows {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ---------- battle ---------- */
|
||||
@@ -1710,18 +1747,19 @@ h3 {
|
||||
.battlefield {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
background:
|
||||
radial-gradient(ellipse 55% 80% at center, rgba(246, 201, 78, 0.12), transparent 66%),
|
||||
radial-gradient(ellipse 80% 55% at center, rgba(246, 201, 78, 0.12), transparent 66%),
|
||||
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;
|
||||
/* Roomy top/bottom padding: pets keep their central row while set-aside
|
||||
cards fan above and attached foods fan below. The bottom needs plenty of
|
||||
room since a pet can carry a big stack of apples (Manatee, Fire Ant, …). */
|
||||
padding: 132px 14px 268px;
|
||||
/* Side padding leaves room for set-aside pets (left) and attached foods
|
||||
(right) to fan out beside each pet; a big apple stack (Manatee, Fire Ant)
|
||||
scrolls horizontally rather than clipping. */
|
||||
padding: 18px 14px;
|
||||
min-height: 150px;
|
||||
overflow-x: auto;
|
||||
box-shadow:
|
||||
@@ -1735,18 +1773,17 @@ h3 {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 2.4rem;
|
||||
align-self: stretch;
|
||||
min-height: 2.4rem;
|
||||
}
|
||||
|
||||
/* A soft seam down the middle of the arena. */
|
||||
/* A soft seam across the middle of the arena, where the two sides clash. */
|
||||
.battle-center::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8%;
|
||||
bottom: 8%;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, transparent, rgba(246, 201, 78, 0.25), transparent);
|
||||
left: 8%;
|
||||
right: 8%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(246, 201, 78, 0.25), transparent);
|
||||
}
|
||||
|
||||
.battle-center-bolt {
|
||||
@@ -1754,12 +1791,20 @@ h3 {
|
||||
filter: drop-shadow(0 0 8px rgba(246, 201, 78, 0.5));
|
||||
}
|
||||
|
||||
/* One player's half. The opponent (top) stacks deck-over-pet; you (bottom)
|
||||
stack pet-over-deck, so both decks sit on the outer edge and the two pets
|
||||
meet at the centre seam. */
|
||||
.battle-side {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Centres the deck stack under (you) / over (opponent) the pet. */
|
||||
.battle-deck {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -1838,53 +1883,57 @@ h3 {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Spawn arcs out from the pet (inner edge) onto the deck. The pet sits to the
|
||||
right of the deck on the left side and to the left on the right side, so
|
||||
each side pulls its spawn in from the opposite direction. */
|
||||
@keyframes summon-from-right {
|
||||
/* Spawn arcs out from the pet onto the deck. The pet sits toward the centre
|
||||
seam — above your deck (bottom half) and below the opponent's (top half) — so
|
||||
each spawn is pulled in from its own pet's side. */
|
||||
@keyframes summon-to-bottom {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(120px, 8px) scale(0.5) rotate(10deg);
|
||||
transform: translateY(-70px) scale(0.5);
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: translate(64px, -22px) scale(1.05) rotate(5deg);
|
||||
transform: translateY(-30px) scale(1.05);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translate(0, -12px) scale(1) rotate(0);
|
||||
transform: translateY(4px) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(0, 2px) scale(0.72);
|
||||
transform: translateY(14px) scale(0.72);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes summon-from-left {
|
||||
@keyframes summon-to-top {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-120px, 8px) scale(0.5) rotate(-10deg);
|
||||
transform: translateY(70px) scale(0.5);
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: translate(-64px, -22px) scale(1.05) rotate(-5deg);
|
||||
transform: translateY(30px) scale(1.05);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translate(0, -12px) scale(1) rotate(0);
|
||||
transform: translateY(-4px) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(0, 2px) scale(0.72);
|
||||
transform: translateY(-14px) scale(0.72);
|
||||
}
|
||||
}
|
||||
|
||||
.summon-pop.summon-from-right {
|
||||
animation-name: summon-from-right;
|
||||
/* You throw from the bottom (deck below the pet); the opponent from the top. */
|
||||
.summon-pop.summon-to-bottom {
|
||||
top: auto;
|
||||
bottom: calc(100% - 24px);
|
||||
animation-name: summon-to-bottom;
|
||||
}
|
||||
|
||||
.summon-pop.summon-from-left {
|
||||
animation-name: summon-from-left;
|
||||
.summon-pop.summon-to-top {
|
||||
top: calc(100% - 24px);
|
||||
animation-name: summon-to-top;
|
||||
}
|
||||
|
||||
/* Apple/bee spawned in the shop, flying out from the creature that made it. */
|
||||
@@ -1948,27 +1997,27 @@ h3 {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/* Set-aside pets (Blowfish, Badger, …) laid out in a row above the active
|
||||
pet, kept until their pending effect resolves. */
|
||||
/* Set-aside pets (Blowfish, Badger, …) laid out in a row to the left of the
|
||||
active pet, kept until their pending effect resolves. */
|
||||
.setaside-row {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
right: calc(100% + 6px);
|
||||
top: 50%;
|
||||
transform: translateY(-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. */
|
||||
/* Attached (or pending) foods fanned to the right of the pet: a horizontal row
|
||||
that overlaps ~¾ so only each card's left edge shows, last card fully on top. */
|
||||
.food-fan {
|
||||
position: absolute;
|
||||
top: calc(100% - 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
left: calc(100% - 6px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
z-index: 4;
|
||||
}
|
||||
@@ -1978,8 +2027,8 @@ h3 {
|
||||
}
|
||||
|
||||
.food-fan-card:not(:first-child) {
|
||||
/* Slightly tighter overlap so a tall apple stack fits the play area. */
|
||||
margin-top: -92px;
|
||||
/* Overlap so a tall apple stack stays compact beside the pet. */
|
||||
margin-left: -64px;
|
||||
}
|
||||
|
||||
/* Released cards (a spent set-aside pet, or a used-up food perk) shrink and
|
||||
@@ -1998,13 +2047,13 @@ h3 {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* A freshly dealt food (apple) sails in from the deck — up and toward the outer
|
||||
edge — and settles onto the fan, rather than blinking into place. Direction
|
||||
follows the side the deck sits on (see food-in-left / food-in-right). */
|
||||
@keyframes food-in-left {
|
||||
/* A freshly dealt food (apple) sails in from the deck and settles onto the fan,
|
||||
rather than blinking into place. The deck sits below the pet on your side and
|
||||
above it on the opponent's, so the apple flies up (bottom) or down (top). */
|
||||
@keyframes food-in-bottom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-64px, -52px) rotate(-14deg) scale(0.6);
|
||||
transform: translate(-30px, 70px) rotate(-12deg) scale(0.6);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -2012,10 +2061,10 @@ h3 {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes food-in-right {
|
||||
@keyframes food-in-top {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(64px, -52px) rotate(14deg) scale(0.6);
|
||||
transform: translate(-30px, -70px) rotate(12deg) scale(0.6);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -2023,27 +2072,27 @@ h3 {
|
||||
}
|
||||
}
|
||||
|
||||
.food-fan-card.food-in-left {
|
||||
animation: food-in-left 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
||||
.food-fan-card.food-in-bottom {
|
||||
animation: food-in-bottom 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
.food-fan-card.food-in-right {
|
||||
animation: food-in-right 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
||||
.food-fan-card.food-in-top {
|
||||
animation: food-in-top 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
/* A pet that faints with a pending effect rises up out of the arena into the
|
||||
set-aside row, shrinking from its arena size to the smaller set-aside size. */
|
||||
/* A pet that faints with a pending effect slides out of the arena into the
|
||||
set-aside row on its left, shrinking to the smaller set-aside size. */
|
||||
@keyframes setaside-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(150px) scale(1.2);
|
||||
transform: translateX(120px) scale(1.2);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2071,61 +2120,68 @@ h3 {
|
||||
}
|
||||
|
||||
/* A pet flips face-up off its deck: it starts over the stack (outer edge) still
|
||||
turned edge-on, then slides in toward its arena slot while turning to face us.
|
||||
The deck sits to the outer side of each unit, so each side pulls the reveal in
|
||||
from its own edge. */
|
||||
@keyframes unit-reveal-left {
|
||||
turned edge-on, then rises in toward its arena slot while turning to face us.
|
||||
The deck sits below your pet and above the opponent's, so each side pulls the
|
||||
reveal in from its own edge. */
|
||||
@keyframes unit-reveal-bottom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-108px) perspective(760px) rotateY(-92deg) scale(0.92);
|
||||
transform: translateY(108px) perspective(760px) rotateX(-92deg) scale(0.92);
|
||||
}
|
||||
45% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) perspective(760px) rotateY(0deg) scale(1);
|
||||
transform: translateY(0) perspective(760px) rotateX(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes unit-reveal-right {
|
||||
@keyframes unit-reveal-top {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(108px) perspective(760px) rotateY(92deg) scale(0.92);
|
||||
transform: translateY(-108px) perspective(760px) rotateX(92deg) scale(0.92);
|
||||
}
|
||||
45% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) perspective(760px) rotateY(0deg) scale(1);
|
||||
transform: translateY(0) perspective(760px) rotateX(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.battle-unit.unit-reveal-left {
|
||||
transform-origin: left center;
|
||||
animation: unit-reveal-left 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
.battle-unit.unit-reveal-bottom {
|
||||
transform-origin: center bottom;
|
||||
animation: unit-reveal-bottom 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
|
||||
.battle-unit.unit-reveal-right {
|
||||
transform-origin: right center;
|
||||
animation: unit-reveal-right 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
.battle-unit.unit-reveal-top {
|
||||
transform-origin: center top;
|
||||
animation: unit-reveal-top 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
|
||||
/* --- rock dice --- */
|
||||
|
||||
/* 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. */
|
||||
/* A little tray of dice, drawn beside the throwing side's deck on the side that
|
||||
faces the centre seam — above your deck, below the opponent's. Fixed slots:
|
||||
the dice scramble in place, then settle showing the rolled rock faces. */
|
||||
.stack-dice {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.battle-deck-bottom .stack-dice {
|
||||
bottom: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.battle-deck-top .stack-dice {
|
||||
top: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.dice-roll {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
@@ -2247,42 +2303,43 @@ h3 {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
@keyframes clash-left {
|
||||
/* You (bottom) lunge up into the seam; the opponent (top) lunges down. */
|
||||
@keyframes clash-bottom {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
35% {
|
||||
transform: translateX(26px) rotate(4deg);
|
||||
transform: translateY(-26px) rotate(2deg);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-6px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes clash-right {
|
||||
@keyframes clash-top {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
35% {
|
||||
transform: translateX(-26px) rotate(-4deg);
|
||||
transform: translateY(26px) rotate(-2deg);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(6px);
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.battle-unit.clash-left {
|
||||
animation: clash-left 500ms ease;
|
||||
.battle-unit.clash-bottom {
|
||||
animation: clash-bottom 500ms ease;
|
||||
}
|
||||
|
||||
.battle-unit.clash-right {
|
||||
animation: clash-right 500ms ease;
|
||||
.battle-unit.clash-top {
|
||||
animation: clash-top 500ms ease;
|
||||
}
|
||||
|
||||
@keyframes dying {
|
||||
@@ -2462,9 +2519,6 @@ h3 {
|
||||
width: 92px;
|
||||
height: 128px;
|
||||
}
|
||||
.arrange-marker {
|
||||
height: 128px;
|
||||
}
|
||||
.card-lg {
|
||||
width: 104px;
|
||||
height: 146px;
|
||||
@@ -2624,22 +2678,12 @@ h3 {
|
||||
max-width: min(60vw, 560px);
|
||||
}
|
||||
|
||||
/* first-right: the first pet sits on the right (matches your own arrangement).
|
||||
first-left: the first pet sits on the left (a rival, who faces you from the
|
||||
right of the board). The "⚔️ first" marker is the first DOM child, so it
|
||||
lands next to the first card at whichever end. */
|
||||
.deck-peek-cards.first-right {
|
||||
direction: rtl;
|
||||
}
|
||||
/* first-left: the lineup runs first-to-last, left to right, with the "⚔️ first"
|
||||
marker as the first DOM child so it lands next to the first card. */
|
||||
.deck-peek-cards.first-left {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* Keep each card's own contents left-to-right regardless of row direction. */
|
||||
.deck-peek-cards > * {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* Compact echo of the arrangement phase's "⚔️ first" indicator. */
|
||||
.deck-peek-first {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user