Fix some card UX.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
||||
import { CardView } from './CardView'
|
||||
import { CardView, CardZoom } from './CardView'
|
||||
import { useCardAnimations } from '../anim'
|
||||
import { useMediaQuery } from '../useMediaQuery'
|
||||
|
||||
interface Props {
|
||||
view: GameView
|
||||
@@ -37,6 +38,11 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
// drag) always calls the fresh closure — see the drag effect below.
|
||||
const moveRef = useRef<(clientY: number) => void>(() => {})
|
||||
const locked = you.ready
|
||||
// Phones can't hover to preview a card, so a tap opens the magnified view
|
||||
// instead. Dragging uses the grip handle, so tapping the card body is free to
|
||||
// mean "read it". Ignored on desktop, which keeps the hover magnifier.
|
||||
const isPhone = useMediaQuery('(max-width: 600px)')
|
||||
const [zoom, setZoom] = useState<{ card: Card; bonus: number } | null>(null)
|
||||
|
||||
// The arrow buttons reorder `order` and the cards glide to their new slot.
|
||||
// A drag reorders live too, and the neighbors the dragged card passes should
|
||||
@@ -250,7 +256,16 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
|
||||
<CardView
|
||||
card={c}
|
||||
bonus={bonuses.get(c.id) ?? 0}
|
||||
noMagnify={dragging}
|
||||
onClick={
|
||||
isPhone && !dragging
|
||||
? () => setZoom({ card: c, bonus: bonuses.get(c.id) ?? 0 })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -272,6 +287,10 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
Lock in & battle ⚔️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{zoom && (
|
||||
<CardZoom card={zoom.card} bonus={zoom.bonus} onClose={() => setZoom(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
|
||||
import { CardView } from './CardView'
|
||||
import { CardView, CardZoom } from './CardView'
|
||||
import { DiceRoll, ROLL_MS } from './DiceRoll'
|
||||
import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed'
|
||||
import { useMediaQuery } from '../useMediaQuery'
|
||||
|
||||
// How long a settled rock roll (and its damage) stays on screen before the
|
||||
// battle advances to the next step.
|
||||
@@ -313,6 +314,25 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
// paused freezes auto-advance so the player can walk the log manually.
|
||||
const [paused, setPaused] = useState(false)
|
||||
|
||||
// Phone tap-to-magnify: tapping a pet/food opens a big readable copy (phones
|
||||
// can't hover to preview). Opening it pauses the replay; closing it resumes
|
||||
// only if the replay was actually playing when we tapped, so a manual pause
|
||||
// (or a finished battle) stays put.
|
||||
const isPhone = useMediaQuery('(max-width: 600px)')
|
||||
const [zoom, setZoom] = useState<{ card: Card; bonus: number; damage: number; dead: boolean } | null>(
|
||||
null,
|
||||
)
|
||||
const resumeAfterZoom = useRef(false)
|
||||
const openZoom = (z: { card: Card; bonus?: number; damage?: number; dead?: boolean }) => {
|
||||
resumeAfterZoom.current = !paused && !done
|
||||
setPaused(true)
|
||||
setZoom({ card: z.card, bonus: z.bonus ?? 0, damage: z.damage ?? 0, dead: !!z.dead })
|
||||
}
|
||||
const closeZoom = () => {
|
||||
setZoom(null)
|
||||
if (resumeAfterZoom.current) setPaused(false)
|
||||
}
|
||||
|
||||
// A rock event on screen scrambles its dice first; only once they settle do
|
||||
// we apply the damage and reveal the result.
|
||||
const showingRock = !done && lastEvent?.type === 'rock'
|
||||
@@ -485,7 +505,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
}`}
|
||||
style={{ zIndex: i + 1 }}
|
||||
>
|
||||
<CardView card={f} size="sm" />
|
||||
<CardView
|
||||
card={f}
|
||||
size="sm"
|
||||
onClick={isPhone ? () => openZoom({ card: f }) : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -512,6 +536,17 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
bonus={s.unit.bonus}
|
||||
damage={s.unit.damage}
|
||||
dead={s.unit.dying}
|
||||
onClick={
|
||||
isPhone
|
||||
? () =>
|
||||
openZoom({
|
||||
card: s.unit!.card,
|
||||
bonus: s.unit!.bonus,
|
||||
damage: s.unit!.damage,
|
||||
dead: s.unit!.dying,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{(s.unit.spooked > 0 || s.unit.exposed > 0) && (
|
||||
<div className="ailment-badges">
|
||||
@@ -717,6 +752,16 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{zoom && (
|
||||
<CardZoom
|
||||
card={zoom.card}
|
||||
bonus={zoom.bonus}
|
||||
damage={zoom.damage}
|
||||
dead={zoom.dead}
|
||||
onClose={closeZoom}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,6 +177,39 @@ export function CardMagnify({
|
||||
)
|
||||
}
|
||||
|
||||
// CardZoom is the touch-friendly "tap to read" overlay: a big centered copy of
|
||||
// a card with a Close button, portaled over everything. Phones have no hover to
|
||||
// pop the magnifier, so a tap opens this instead (the shop has its own richer
|
||||
// version with Buy/Sell). Battle decorations (buffs, damage, death) ride along
|
||||
// so the zoomed card matches what's on the board.
|
||||
export function CardZoom({
|
||||
card,
|
||||
bonus = 0,
|
||||
damage = 0,
|
||||
dead,
|
||||
onClose,
|
||||
}: {
|
||||
card: Card
|
||||
bonus?: number
|
||||
damage?: number
|
||||
dead?: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="card-focus" onClick={(e) => e.stopPropagation()}>
|
||||
<CardView card={card} size="lg" bonus={bonus} damage={damage} dead={dead} preview />
|
||||
<div className="card-focus-actions">
|
||||
<button className="btn btn-ghost" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
card: Card
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
@@ -265,18 +298,27 @@ export function CardView({
|
||||
</div>
|
||||
</div>
|
||||
{/* Lower panel: the suit hat, name, and tier die on one row, the ability
|
||||
beneath — a white card in a rounded green frame. */}
|
||||
beneath — a white card in a rounded green frame. Token cards (apples,
|
||||
ailments) have neither a suit nor a tier, so their name takes the whole
|
||||
row instead of being squeezed between two empty spacer slots (which, on
|
||||
a small battle card, left "Apple" only a few pixels and clipped it). */}
|
||||
<div className="card-panel">
|
||||
<div className="card-panel-head">
|
||||
{card.suit ? (
|
||||
<span className={`suit-dot card-suit suit-${card.suit}`} aria-hidden />
|
||||
) : (
|
||||
<span className="card-panel-slot" aria-hidden />
|
||||
)}
|
||||
<div className={`card-panel-head ${!card.suit && !card.tier ? 'is-bare' : ''}`}>
|
||||
{(card.suit || card.tier) &&
|
||||
(card.suit ? (
|
||||
<span className={`suit-dot card-suit suit-${card.suit}`} aria-hidden />
|
||||
) : (
|
||||
<span className="card-panel-slot" aria-hidden />
|
||||
))}
|
||||
<span className="card-name" ref={nameRef}>
|
||||
{card.name}
|
||||
</span>
|
||||
{card.tier ? <TierDie tier={card.tier} /> : <span className="card-panel-slot" aria-hidden />}
|
||||
{(card.suit || card.tier) &&
|
||||
(card.tier ? (
|
||||
<TierDie tier={card.tier} />
|
||||
) : (
|
||||
<span className="card-panel-slot" aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
<div className="card-effect" ref={effectRef}>
|
||||
{card.effectText
|
||||
|
||||
Reference in New Issue
Block a user