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 type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
||||||
import { CardView } from './CardView'
|
import { CardView } from './CardView'
|
||||||
import { useCardAnimations } from '../anim'
|
import { useCardAnimations } from '../anim'
|
||||||
@@ -9,31 +9,21 @@ interface Props {
|
|||||||
send: (msg: ClientMessage) => void
|
send: (msg: ClientMessage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArrangePhase lets the player order their deck for battle. Rightmost card
|
// ArrangePhase lets the player order their deck for battle. The list runs top to
|
||||||
// fights first; food cards buff the next pet to their left... i.e. foods
|
// bottom in play order: the topmost card fights first, and food cards buff the
|
||||||
// apply to the next pet later in the play order. Internally `order` stays in
|
// next pet below them. `order` stays in play order (index 0 fights first) to
|
||||||
// play order (index 0 fights first) to match the backend; we only reverse for
|
// match the backend. Drag cards or use the arrow buttons to reorder, then lock
|
||||||
// display. Drag cards or use the arrow buttons to reorder, then lock in.
|
// in.
|
||||||
export function ArrangePhase({ view, you, send }: Props) {
|
export function ArrangePhase({ view, you, send }: Props) {
|
||||||
const [order, setOrder] = useState<Card[]>(you.deck ?? [])
|
const [order, setOrder] = useState<Card[]>(you.deck ?? [])
|
||||||
const dragIndex = useRef<number | null>(null)
|
const dragIndex = useRef<number | null>(null)
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
const locked = you.ready
|
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.
|
// The arrow buttons reorder `order` and the cards glide to their new slot.
|
||||||
// Drag-and-drop reorders instantly instead: a drag fires continuously, so
|
// Drag-and-drop reorders instantly instead: a drag fires continuously, so
|
||||||
// animating every crossing looks frantic — we suppress it while dragging.
|
// animating every crossing looks frantic — we suppress it while dragging.
|
||||||
const arrangeAnim = useCardAnimations(order, (c) => c.id, !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
|
// Resync only if the deck's actual contents changed — every broadcast
|
||||||
// creates a fresh array, and blindly resetting would wipe an in-progress
|
// 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])
|
}, [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) {
|
function move(from: number, to: number) {
|
||||||
if (to < 0 || to >= order.length) return
|
if (to < 0 || to >= order.length) return
|
||||||
setOrder((o) => {
|
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>()
|
const bonuses = new Map<string, number>()
|
||||||
{
|
{
|
||||||
let pendingApples = 0
|
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 = (() => {
|
const trailingFoods = (() => {
|
||||||
let n = 0
|
let n = 0
|
||||||
for (let i = order.length - 1; i >= 0 && order[i].kind === 'food'; i--) n++
|
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>
|
<span className="status-hot">Arrange your battle line</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
The <strong>rightmost</strong> card fights first. Food cards power up the
|
The <strong>topmost</strong> card fights first. Food cards power up the
|
||||||
next pet to their <strong>left</strong>.
|
next pet <strong>below</strong> them.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div className="arrange-col" ref={arrangeAnim.containerRef}>
|
||||||
className="arrange-row"
|
<div className="arrange-marker">⚔️ first to fight</div>
|
||||||
ref={(el) => {
|
|
||||||
trayRef.current = el
|
|
||||||
arrangeAnim.containerRef.current = el
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="arrange-marker">⚔️ first</div>
|
|
||||||
{order.map((c, i) => (
|
{order.map((c, i) => (
|
||||||
<div
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className="arrange-card"
|
className="arrange-card"
|
||||||
data-flip-key={c.id}
|
data-flip-key={c.id}
|
||||||
ref={(el) => {
|
|
||||||
cardRefs.current[i] = el
|
|
||||||
}}
|
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => {
|
onDragStart={() => {
|
||||||
dragIndex.current = i
|
dragIndex.current = i
|
||||||
@@ -228,57 +118,33 @@ export function ArrangePhase({ view, you, send }: Props) {
|
|||||||
setDragging(false)
|
setDragging(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
|
|
||||||
<div className="arrange-arrows">
|
<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
|
<button
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
onClick={() => move(i, i - 1)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{trailingFoods > 0 && (
|
{trailingFoods > 0 && (
|
||||||
<p className="hint warn-text">
|
<p className="hint warn-text">
|
||||||
⚠️ {trailingFoods} food card{trailingFoods > 1 ? 's' : ''} on the far
|
⚠️ {trailingFoods} food card{trailingFoods > 1 ? 's' : ''} at the
|
||||||
left will be wasted!
|
bottom will be wasted!
|
||||||
</p>
|
</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
|
// (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
|
// 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.
|
// 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,
|
null,
|
||||||
)
|
)
|
||||||
const lineups = battle.lineups
|
const lineups = battle.lineups
|
||||||
@@ -319,7 +319,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
? lastEvent.dice
|
? lastEvent.dice
|
||||||
: null
|
: 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 s = sides[seat]
|
||||||
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
|
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
|
||||||
const clashDying = lastEvent?.type === 'clash' && 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.type === 'reveal' && lastEvent.card?.kind === 'food'))
|
||||||
? lastEvent.card?.id
|
? lastEvent.card?.id
|
||||||
: null
|
: 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 =
|
const newSetAsideId =
|
||||||
!done && lastEvent?.type === 'setaside' && lastEvent.seat === seat
|
!done && lastEvent?.type === 'setaside' && lastEvent.seat === seat
|
||||||
? lastEvent.card?.id
|
? lastEvent.card?.id
|
||||||
@@ -346,47 +350,49 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
|
|
||||||
const lineup = lineups?.[seat] ?? []
|
const lineup = lineups?.[seat] ?? []
|
||||||
const stackEl = (
|
const stackEl = (
|
||||||
<div
|
<div className={`battle-deck battle-deck-${pos}`}>
|
||||||
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
|
<div
|
||||||
onMouseEnter={
|
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
|
||||||
lineup.length
|
onMouseEnter={
|
||||||
? (e) => setPeek({ seat, dir, rect: e.currentTarget.getBoundingClientRect() })
|
lineup.length
|
||||||
: undefined
|
? (e) => setPeek({ seat, pos, rect: e.currentTarget.getBoundingClientRect() })
|
||||||
}
|
: undefined
|
||||||
onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))}
|
}
|
||||||
title={lineup.length ? 'Hover to see the whole deck' : undefined}
|
onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))}
|
||||||
>
|
title={lineup.length ? 'Hover to see the whole deck' : undefined}
|
||||||
{s.stack > 0 ? (
|
>
|
||||||
<div className="card-back">
|
{s.stack > 0 ? (
|
||||||
<span className="card-back-count">{s.stack}</span>
|
<div className="card-back">
|
||||||
</div>
|
<span className="card-back-count">{s.stack}</span>
|
||||||
) : (
|
</div>
|
||||||
<div className="card-slot-empty stack-empty" />
|
) : (
|
||||||
)}
|
<div className="card-slot-empty stack-empty" />
|
||||||
{summoning && lastEvent?.card && (
|
)}
|
||||||
// Fly the spawned apple/bee out from where the pet stood (the unit
|
{summoning && lastEvent?.card && (
|
||||||
// zone sits on the inner edge) onto the top of the deck, so it reads
|
// Fly the spawned apple/bee out from where the pet stood (toward the
|
||||||
// as coming from the pet that just fainted.
|
// clash line) onto the deck, so it reads as coming from the pet that
|
||||||
<div className={`summon-pop ${dir === 'left' ? 'summon-from-right' : 'summon-from-left'}`}>
|
// just fainted.
|
||||||
<CardView card={lastEvent.card} size="sm" />
|
<div className={`summon-pop summon-to-${pos}`}>
|
||||||
</div>
|
<CardView card={lastEvent.card} size="sm" />
|
||||||
)}
|
</div>
|
||||||
{milling && lastEvent?.card && (
|
)}
|
||||||
<div className="summon-pop mill-pop">
|
{milling && lastEvent?.card && (
|
||||||
<CardView card={lastEvent.card} size="sm" dead />
|
<div className="summon-pop mill-pop">
|
||||||
</div>
|
<CardView card={lastEvent.card} size="sm" dead />
|
||||||
)}
|
</div>
|
||||||
{rockDice && lastEvent?.seat === seat && (
|
)}
|
||||||
// The dice roll sits directly under the throwing side's deck.
|
{rockDice && lastEvent?.seat === seat && (
|
||||||
<div className="stack-dice">
|
// The dice roll sits beside the throwing side's deck.
|
||||||
<DiceRoll key={step} dice={rockDice} side={dir} />
|
<div className="stack-dice">
|
||||||
</div>
|
<DiceRoll key={step} dice={rockDice} side={pos} />
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set-aside pets (Blowfish, Badger, …) sit in a row above the active pet
|
// Set-aside pets (Blowfish, Badger, …) sit in a row to the left of the
|
||||||
// until their pending effect resolves.
|
// active pet until their pending effect resolves.
|
||||||
const setAsideEl = s.setAside.length > 0 && (
|
const setAsideEl = s.setAside.length > 0 && (
|
||||||
<div className="setaside-row">
|
<div className="setaside-row">
|
||||||
{s.setAside.map((c) => (
|
{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
|
// 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
|
// next) fan out to the right of it: a horizontal row overlapping ~¾, so
|
||||||
// card's top edge shows — like cards laid out on a table.
|
// only each card's left edge shows — like cards laid out on a table.
|
||||||
const foods = s.unit ? s.unit.foods : s.pending
|
const foods = s.unit ? s.unit.foods : s.pending
|
||||||
const foodFanEl = foods.length > 0 && (
|
const foodFanEl = foods.length > 0 && (
|
||||||
<div className="food-fan">
|
<div className="food-fan">
|
||||||
@@ -412,7 +418,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
<div
|
<div
|
||||||
key={f.id}
|
key={f.id}
|
||||||
className={`food-fan-card ${s.leaving.includes(f.id) ? 'is-leaving' : ''} ${
|
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 }}
|
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'}`}
|
key={`${s.unit.card.id}-${clashing || rockVictim ? step : 'idle'}`}
|
||||||
className={[
|
className={[
|
||||||
'battle-unit',
|
'battle-unit',
|
||||||
clashing || clashDying ? `clash-${dir}` : '',
|
clashing || clashDying ? `clash-${pos}` : '',
|
||||||
s.unit.dying ? 'unit-dying' : '',
|
s.unit.dying ? 'unit-dying' : '',
|
||||||
revealing ? `unit-reveal unit-reveal-${dir}` : '',
|
revealing ? `unit-reveal unit-reveal-${pos}` : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
@@ -454,15 +460,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return dir === 'left' ? (
|
return (
|
||||||
<div className="battle-side">
|
<div className={`battle-side battle-side-${pos}`}>
|
||||||
{stackEl}
|
{pos === 'top' ? (
|
||||||
{unitEl}
|
<>
|
||||||
</div>
|
{stackEl}
|
||||||
) : (
|
{unitEl}
|
||||||
<div className="battle-side">
|
</>
|
||||||
{unitEl}
|
) : (
|
||||||
{stackEl}
|
<>
|
||||||
|
{unitEl}
|
||||||
|
{stackEl}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -520,35 +530,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="battlefield">
|
<div className="battlefield">
|
||||||
{renderSide(youSeat, 'left')}
|
{renderSide(oppSeat, 'top')}
|
||||||
<div className="battle-center" aria-hidden>
|
<div className="battle-center" aria-hidden>
|
||||||
<span className="battle-center-bolt">⚡</span>
|
<span className="battle-center-bolt">⚡</span>
|
||||||
</div>
|
</div>
|
||||||
{renderSide(oppSeat, 'right')}
|
{renderSide(youSeat, 'bottom')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{peek &&
|
{peek &&
|
||||||
(() => {
|
(() => {
|
||||||
const lineup = lineups?.[peek.seat] ?? []
|
const lineup = lineups?.[peek.seat] ?? []
|
||||||
if (!lineup.length) return null
|
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 = {
|
const style: React.CSSProperties = {
|
||||||
bottom: window.innerHeight - peek.rect.top + 8,
|
left: centerX,
|
||||||
...(peek.dir === 'left'
|
transform: 'translateX(-50%)',
|
||||||
? { left: peek.rect.left }
|
...(peek.pos === 'bottom'
|
||||||
: { right: window.innerWidth - peek.rect.right }),
|
? { 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 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(
|
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">
|
<div className="deck-peek-label">
|
||||||
{mine ? 'Your' : 'Opponent’s'} deck · {lineup.length} card
|
{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>
|
||||||
<div className={`deck-peek-cards first-${firstSide}`}>
|
<div className="deck-peek-cards first-left">
|
||||||
<div className="deck-peek-first">⚔️ first</div>
|
<div className="deck-peek-first">⚔️ first</div>
|
||||||
{lineup.map((c, i) => (
|
{lineup.map((c, i) => (
|
||||||
<CardView key={c.id || i} card={c} size="sm" />
|
<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
|
// 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`
|
// 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.
|
// 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 [display, setDisplay] = useState<number[]>(dice)
|
||||||
const [settled, setSettled] = useState(false)
|
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.
|
// (its arranged lineup is already public). Shown as a centered modal.
|
||||||
const [deckPeek, setDeckPeek] = useState<{ seat: number } | null>(null)
|
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
|
// 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
|
||||||
@@ -87,7 +92,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
|||||||
if (view) {
|
if (view) {
|
||||||
view.shopRow?.forEach(add)
|
view.shopRow?.forEach(add)
|
||||||
view.players?.forEach((p) => p.deck?.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))
|
view.battle?.events?.forEach((ev) => add(ev.card))
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
@@ -190,12 +195,26 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
|||||||
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
|
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
|
||||||
</main>
|
</main>
|
||||||
{view.phase !== 'lobby' && (
|
{view.phase !== 'lobby' && (
|
||||||
<EventLog
|
<>
|
||||||
entries={entries}
|
{/* Floating toggle — only shown on narrow screens (CSS). */}
|
||||||
youSeat={view.youSeat}
|
<button
|
||||||
battleLines={battleLines}
|
className="log-fab"
|
||||||
cardLookup={cardLookup}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+216
-172
@@ -531,10 +531,13 @@ h3 {
|
|||||||
min-height: 0;
|
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 {
|
.table-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 26px 20px 48px;
|
padding: 22px 16px 48px;
|
||||||
max-width: 1100px;
|
max-width: 620px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -668,24 +671,94 @@ h3 {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(246, 201, 78, 0.45);
|
box-shadow: inset 0 0 0 1px rgba(246, 201, 78, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
/* On wide screens the dock wrapper is transparent to layout, so the log docks
|
||||||
.table-body {
|
as a right column exactly as its own rules describe. */
|
||||||
flex-direction: column;
|
.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%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
position: static;
|
position: static;
|
||||||
max-height: 220px;
|
border-left: 1px solid rgba(0, 0, 0, 0.4);
|
||||||
border-left: none;
|
|
||||||
border-top: 3px solid rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
}
|
||||||
.event-log.is-collapsed {
|
|
||||||
|
.event-log-dock .event-log.is-collapsed {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.event-log.is-collapsed .event-log-head {
|
|
||||||
|
.event-log-dock .event-log.is-collapsed .event-log-head {
|
||||||
writing-mode: horizontal-tb;
|
writing-mode: horizontal-tb;
|
||||||
height: auto;
|
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 ---------- */
|
/* ---------- lobby ---------- */
|
||||||
@@ -1567,79 +1640,42 @@ h3 {
|
|||||||
gap: 20px;
|
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;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
/* Battle line reads right-to-left, wrapping downward: card 0 (fights first)
|
flex-direction: column;
|
||||||
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;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
gap: 12px;
|
||||||
justify-content: flex-start;
|
|
||||||
background:
|
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);
|
rgba(0, 0, 0, 0.24);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
/* Extra right padding reserves the strip the "first" marker sits in, so its
|
padding: 16px 18px 20px;
|
||||||
width doesn't shove the top row left of the wrapped rows below it. */
|
|
||||||
padding: 20px 52px 20px 18px;
|
|
||||||
min-height: 224px;
|
min-height: 224px;
|
||||||
box-shadow: var(--tray-inset);
|
box-shadow: var(--tray-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pinned out of flow to the reserved strip beside the first (rightmost) card,
|
/* The "first to fight" label sits at the top of the column, above the first
|
||||||
vertically centred on the top row, so it never affects card alignment. */
|
card. It's a static first child (no data-flip-key), so the FLIP animator
|
||||||
|
ignores it. */
|
||||||
.arrange-marker {
|
.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);
|
font-family: var(--font-display);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
writing-mode: vertical-rl;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SVG overlay for the wrap-around connector arrows. Absolute so it sits out of
|
/* Each row: reorder arrows on the left, the card on the right. */
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrange-card {
|
.arrange-card {
|
||||||
direction: ltr;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1649,7 +1685,8 @@ h3 {
|
|||||||
|
|
||||||
.arrange-arrows {
|
.arrange-arrows {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5px;
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- battle ---------- */
|
/* ---------- battle ---------- */
|
||||||
@@ -1710,18 +1747,19 @@ h3 {
|
|||||||
.battlefield {
|
.battlefield {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
background:
|
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));
|
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: 1px solid rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
/* Roomy top/bottom padding: pets keep their central row while set-aside
|
/* Side padding leaves room for set-aside pets (left) and attached foods
|
||||||
cards fan above and attached foods fan below. The bottom needs plenty of
|
(right) to fan out beside each pet; a big apple stack (Manatee, Fire Ant)
|
||||||
room since a pet can carry a big stack of apples (Manatee, Fire Ant, …). */
|
scrolls horizontally rather than clipping. */
|
||||||
padding: 132px 14px 268px;
|
padding: 18px 14px;
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -1735,18 +1773,17 @@ h3 {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
min-width: 2.4rem;
|
min-height: 2.4rem;
|
||||||
align-self: stretch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 {
|
.battle-center::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8%;
|
left: 8%;
|
||||||
bottom: 8%;
|
right: 8%;
|
||||||
width: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(180deg, transparent, rgba(246, 201, 78, 0.25), transparent);
|
background: linear-gradient(90deg, transparent, rgba(246, 201, 78, 0.25), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-center-bolt {
|
.battle-center-bolt {
|
||||||
@@ -1754,12 +1791,20 @@ h3 {
|
|||||||
filter: drop-shadow(0 0 8px rgba(246, 201, 78, 0.5));
|
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 {
|
.battle-side {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Centres the deck stack under (you) / over (opponent) the pet. */
|
||||||
|
.battle-deck {
|
||||||
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1838,53 +1883,57 @@ h3 {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Spawn arcs out from the pet (inner edge) onto the deck. The pet sits to the
|
/* Spawn arcs out from the pet onto the deck. The pet sits toward the centre
|
||||||
right of the deck on the left side and to the left on the right side, so
|
seam — above your deck (bottom half) and below the opponent's (top half) — so
|
||||||
each side pulls its spawn in from the opposite direction. */
|
each spawn is pulled in from its own pet's side. */
|
||||||
@keyframes summon-from-right {
|
@keyframes summon-to-bottom {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(120px, 8px) scale(0.5) rotate(10deg);
|
transform: translateY(-70px) scale(0.5);
|
||||||
}
|
}
|
||||||
25% {
|
25% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(64px, -22px) scale(1.05) rotate(5deg);
|
transform: translateY(-30px) scale(1.05);
|
||||||
}
|
}
|
||||||
70% {
|
70% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(0, -12px) scale(1) rotate(0);
|
transform: translateY(4px) scale(1);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(0, 2px) scale(0.72);
|
transform: translateY(14px) scale(0.72);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes summon-from-left {
|
@keyframes summon-to-top {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-120px, 8px) scale(0.5) rotate(-10deg);
|
transform: translateY(70px) scale(0.5);
|
||||||
}
|
}
|
||||||
25% {
|
25% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-64px, -22px) scale(1.05) rotate(-5deg);
|
transform: translateY(30px) scale(1.05);
|
||||||
}
|
}
|
||||||
70% {
|
70% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(0, -12px) scale(1) rotate(0);
|
transform: translateY(-4px) scale(1);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(0, 2px) scale(0.72);
|
transform: translateY(-14px) scale(0.72);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.summon-pop.summon-from-right {
|
/* You throw from the bottom (deck below the pet); the opponent from the top. */
|
||||||
animation-name: summon-from-right;
|
.summon-pop.summon-to-bottom {
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(100% - 24px);
|
||||||
|
animation-name: summon-to-bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summon-pop.summon-from-left {
|
.summon-pop.summon-to-top {
|
||||||
animation-name: summon-from-left;
|
top: calc(100% - 24px);
|
||||||
|
animation-name: summon-to-top;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Apple/bee spawned in the shop, flying out from the creature that made it. */
|
/* Apple/bee spawned in the shop, flying out from the creature that made it. */
|
||||||
@@ -1948,27 +1997,27 @@ h3 {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Set-aside pets (Blowfish, Badger, …) laid out in a row above the active
|
/* Set-aside pets (Blowfish, Badger, …) laid out in a row to the left of the
|
||||||
pet, kept until their pending effect resolves. */
|
active pet, kept until their pending effect resolves. */
|
||||||
.setaside-row {
|
.setaside-row {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 6px);
|
right: calc(100% + 6px);
|
||||||
left: 50%;
|
top: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateY(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Attached (or pending) foods fanned below the pet: a vertical stack that
|
/* Attached (or pending) foods fanned to the right of the pet: a horizontal row
|
||||||
overlaps ~¾ so only each card's top edge shows, last card fully on top. */
|
that overlaps ~¾ so only each card's left edge shows, last card fully on top. */
|
||||||
.food-fan {
|
.food-fan {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% - 6px);
|
left: calc(100% - 6px);
|
||||||
left: 50%;
|
top: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateY(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
@@ -1978,8 +2027,8 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.food-fan-card:not(:first-child) {
|
.food-fan-card:not(:first-child) {
|
||||||
/* Slightly tighter overlap so a tall apple stack fits the play area. */
|
/* Overlap so a tall apple stack stays compact beside the pet. */
|
||||||
margin-top: -92px;
|
margin-left: -64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Released cards (a spent set-aside pet, or a used-up food perk) shrink and
|
/* Released cards (a spent set-aside pet, or a used-up food perk) shrink and
|
||||||
@@ -1998,13 +2047,13 @@ h3 {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A freshly dealt food (apple) sails in from the deck — up and toward the outer
|
/* A freshly dealt food (apple) sails in from the deck and settles onto the fan,
|
||||||
edge — and settles onto the fan, rather than blinking into place. Direction
|
rather than blinking into place. The deck sits below the pet on your side and
|
||||||
follows the side the deck sits on (see food-in-left / food-in-right). */
|
above it on the opponent's, so the apple flies up (bottom) or down (top). */
|
||||||
@keyframes food-in-left {
|
@keyframes food-in-bottom {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-64px, -52px) rotate(-14deg) scale(0.6);
|
transform: translate(-30px, 70px) rotate(-12deg) scale(0.6);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -2012,10 +2061,10 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes food-in-right {
|
@keyframes food-in-top {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(64px, -52px) rotate(14deg) scale(0.6);
|
transform: translate(-30px, -70px) rotate(12deg) scale(0.6);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -2023,27 +2072,27 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.food-fan-card.food-in-left {
|
.food-fan-card.food-in-bottom {
|
||||||
animation: food-in-left 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
animation: food-in-bottom 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.food-fan-card.food-in-right {
|
.food-fan-card.food-in-top {
|
||||||
animation: food-in-right 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
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
|
/* A pet that faints with a pending effect slides out of the arena into the
|
||||||
set-aside row, shrinking from its arena size to the smaller set-aside size. */
|
set-aside row on its left, shrinking to the smaller set-aside size. */
|
||||||
@keyframes setaside-in {
|
@keyframes setaside-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(150px) scale(1.2);
|
transform: translateX(120px) scale(1.2);
|
||||||
}
|
}
|
||||||
55% {
|
55% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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
|
/* 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.
|
turned edge-on, then rises 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
|
The deck sits below your pet and above the opponent's, so each side pulls the
|
||||||
from its own edge. */
|
reveal in from its own edge. */
|
||||||
@keyframes unit-reveal-left {
|
@keyframes unit-reveal-bottom {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-108px) perspective(760px) rotateY(-92deg) scale(0.92);
|
transform: translateY(108px) perspective(760px) rotateX(-92deg) scale(0.92);
|
||||||
}
|
}
|
||||||
45% {
|
45% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(108px) perspective(760px) rotateY(92deg) scale(0.92);
|
transform: translateY(-108px) perspective(760px) rotateX(92deg) scale(0.92);
|
||||||
}
|
}
|
||||||
45% {
|
45% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 {
|
.battle-unit.unit-reveal-bottom {
|
||||||
transform-origin: left center;
|
transform-origin: center bottom;
|
||||||
animation: unit-reveal-left 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
animation: unit-reveal-bottom 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-unit.unit-reveal-right {
|
.battle-unit.unit-reveal-top {
|
||||||
transform-origin: right center;
|
transform-origin: center top;
|
||||||
animation: unit-reveal-right 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
animation: unit-reveal-top 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- rock dice --- */
|
/* --- rock dice --- */
|
||||||
|
|
||||||
/* A little tray of dice, drawn just below the throwing side's deck (see
|
/* A little tray of dice, drawn beside the throwing side's deck on the side that
|
||||||
.stack-dice). Fixed slots: the dice scramble in place, then settle showing
|
faces the centre seam — above your deck, below the opponent's. Fixed slots:
|
||||||
the rolled rock faces. */
|
the dice scramble in place, then settle showing the rolled rock faces. */
|
||||||
.stack-dice {
|
.stack-dice {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 8px);
|
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.battle-deck-bottom .stack-dice {
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-deck-top .stack-dice {
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
}
|
||||||
|
|
||||||
.dice-roll {
|
.dice-roll {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
@@ -2247,42 +2303,43 @@ h3 {
|
|||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes clash-left {
|
/* You (bottom) lunge up into the seam; the opponent (top) lunges down. */
|
||||||
|
@keyframes clash-bottom {
|
||||||
0% {
|
0% {
|
||||||
transform: translateX(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
35% {
|
35% {
|
||||||
transform: translateX(26px) rotate(4deg);
|
transform: translateY(-26px) rotate(2deg);
|
||||||
}
|
}
|
||||||
60% {
|
60% {
|
||||||
transform: translateX(-6px);
|
transform: translateY(6px);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateX(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes clash-right {
|
@keyframes clash-top {
|
||||||
0% {
|
0% {
|
||||||
transform: translateX(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
35% {
|
35% {
|
||||||
transform: translateX(-26px) rotate(-4deg);
|
transform: translateY(26px) rotate(-2deg);
|
||||||
}
|
}
|
||||||
60% {
|
60% {
|
||||||
transform: translateX(6px);
|
transform: translateY(-6px);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateX(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-unit.clash-left {
|
.battle-unit.clash-bottom {
|
||||||
animation: clash-left 500ms ease;
|
animation: clash-bottom 500ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-unit.clash-right {
|
.battle-unit.clash-top {
|
||||||
animation: clash-right 500ms ease;
|
animation: clash-top 500ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dying {
|
@keyframes dying {
|
||||||
@@ -2462,9 +2519,6 @@ h3 {
|
|||||||
width: 92px;
|
width: 92px;
|
||||||
height: 128px;
|
height: 128px;
|
||||||
}
|
}
|
||||||
.arrange-marker {
|
|
||||||
height: 128px;
|
|
||||||
}
|
|
||||||
.card-lg {
|
.card-lg {
|
||||||
width: 104px;
|
width: 104px;
|
||||||
height: 146px;
|
height: 146px;
|
||||||
@@ -2624,22 +2678,12 @@ h3 {
|
|||||||
max-width: min(60vw, 560px);
|
max-width: min(60vw, 560px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* first-right: the first pet sits on the right (matches your own arrangement).
|
/* first-left: the lineup runs first-to-last, left to right, with the "⚔️ first"
|
||||||
first-left: the first pet sits on the left (a rival, who faces you from the
|
marker as the first DOM child so it lands next to the first card. */
|
||||||
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;
|
|
||||||
}
|
|
||||||
.deck-peek-cards.first-left {
|
.deck-peek-cards.first-left {
|
||||||
direction: ltr;
|
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. */
|
/* Compact echo of the arrangement phase's "⚔️ first" indicator. */
|
||||||
.deck-peek-first {
|
.deck-peek-first {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user