diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index 2538543..28353a4 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -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) { - + setZoom({ card: c, bonus: bonuses.get(c.id) ?? 0 }) + : undefined + } + /> ) @@ -272,6 +287,10 @@ export function ArrangePhase({ view, you, send }: Props) { Lock in & battle ⚔️ + + {zoom && ( + setZoom(null)} /> + )} ) } diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 75a1c4f..6b430ab 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -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 }} > - + openZoom({ card: f }) : undefined} + /> ))} @@ -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) && (
@@ -717,6 +752,16 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
, document.body, )} + + {zoom && ( + + )} ) } diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index 19b5c42..36bbae0 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -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( +
+
e.stopPropagation()}> + +
+ +
+
+
, + document.body, + ) +} + interface Props { card: Card size?: 'sm' | 'md' | 'lg' @@ -265,18 +298,27 @@ export function CardView({ {/* 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). */}
-
- {card.suit ? ( - - ) : ( - - )} +
+ {(card.suit || card.tier) && + (card.suit ? ( + + ) : ( + + ))} {card.name} - {card.tier ? : } + {(card.suit || card.tier) && + (card.tier ? ( + + ) : ( + + ))}
{card.effectText diff --git a/web/src/styles.css b/web/src/styles.css index 491dbc3..68bfe41 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1237,6 +1237,14 @@ h3 { padding-bottom: 2px; } +/* Token cards (apples, ailments) carry no suit dot or tier die, so their name + spans the whole row instead of being pinched between two empty spacer slots — + on a small battle card that pinch left only a few pixels and clipped the + title down to "A". */ +.card-panel-head.is-bare { + grid-template-columns: minmax(0, 1fr); +} + /* The suit marker: a plain enamel dot (see .suit-dot), sized to sit opposite the tier die in the name row. */ .card-suit { @@ -1402,10 +1410,14 @@ h3 { font-size: 0.74rem; } +/* Damage marker sits at the bottom-centre of the illustration so it never + overlaps the power badge (top-centre) — on a narrow battle card the old + top-right spot rode on top of the power and hid it. */ .card-damage { position: absolute; - top: 5%; - right: 5%; + bottom: 4%; + left: 50%; + transform: translateX(-50%); z-index: 3; font-family: var(--font-display); color: #fff;