diff --git a/improvements.txt b/improvements.txt deleted file mode 100644 index 578d36b..0000000 --- a/improvements.txt +++ /dev/null @@ -1,8 +0,0 @@ -- When you're out of coins, highlight the pass button so you know to click it -- When it becomes your turn in the shop phase, do some big animation of "your turn" across the screen or something, with a sound. It's easy to not realize it's your turn right now. -- I found one occurrence of the rock animation not playing for the opponent. It was deterministic for this battle (if I rewound and replayed, it still skipped) but it worked in other battles. -- Under the hover state of the card, have a explainer box for the trigger -- In the shop, the coints you have remaining should be big golden discs above the buy row, instead of a little icon in the toolbar. -- The squirrel card has two colums to list it's two abilities, but we should just show them stacked vertically instead. -- The crocodile didn't seem to work. I played my last pet, and my opponent had a crocodile set aside, but it didn't roll the rocks. -- If you have lots of apples, they can go off-screen when they stack. Maybe make the play area larger? diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index d1e7c6a..506af8c 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -82,6 +82,62 @@ function triggersOf(text?: string): string[] { return out } +// TriggerGloss renders the plain-English explanation of a card's trigger words, +// shown beneath the hover magnifier (and nothing at all if none apply). +function TriggerGloss({ card }: { card: Card }) { + const trigs = triggersOf(card.effectText).filter((t) => TRIGGER_GLOSS[t]) + if (trigs.length === 0) return null + return ( +
+ {trigs.map((t) => ( +
+ {t} — {TRIGGER_GLOSS[t]} +
+ ))} +
+ ) +} + +// CardMagnify portals a large floating copy of a card near the cursor, clamped +// into the viewport. It's the shared hover-preview used both by CardView's own +// magnifier and by card references in the event log. +export function CardMagnify({ + card, + x, + y, + bonus = 0, + damage = 0, + dead, + flip, +}: { + card: Card + x: number + y: number + bonus?: number + damage?: number + dead?: boolean + // flip anchors the preview to the right of the cursor and grows it leftward, + // for hovers near the right edge (e.g. the event log) where the default + // rightward growth would run off-screen. + flip?: boolean +}) { + const top = Math.min(Math.max(y - 120, 8), window.innerHeight - 320) + return createPortal( +
+ + +
, + document.body, + ) +} + interface Props { card: Card size?: 'sm' | 'md' | 'lg' @@ -135,33 +191,9 @@ export function CardView({ // Place the magnified preview near the cursor, clamped into the viewport. const previewEl = - hover && !preview && !noMagnify - ? createPortal( -
- - {(() => { - const trigs = triggersOf(card.effectText).filter((t) => TRIGGER_GLOSS[t]) - if (trigs.length === 0) return null - return ( -
- {trigs.map((t) => ( -
- {t} — {TRIGGER_GLOSS[t]} -
- ))} -
- ) - })()} -
, - document.body, - ) - : null + hover && !preview && !noMagnify ? ( + + ) : null return (
} function seatClass(seat: number, youSeat: number): string { @@ -61,14 +66,56 @@ function seatClass(seat: number, youSeat: number): string { return seat === youSeat ? 'log-you' : 'log-opp' } +// LogCardRef is a card name embedded in a log line; hovering it magnifies the +// card the same way hovering a real card on the board does. +function LogCardRef({ name, card }: { name: string; card: Card }) { + const [hover, setHover] = useState<{ x: number; y: number } | null>(null) + return ( + setHover({ x: e.clientX, y: e.clientY })} + onMouseMove={(e) => setHover({ x: e.clientX, y: e.clientY })} + onMouseLeave={() => setHover(null)} + > + {name} + {hover && } + + ) +} + +// buildNameRegex compiles one alternation matching any known card name at a +// word boundary, longest name first so multi-word names win over the single +// words inside them ("Blue-Ringed Octopus" before "Octopus"). +function buildNameRegex(lookup?: Map): RegExp | null { + if (!lookup || lookup.size === 0) return null + const names = [...lookup.keys()].sort((a, b) => b.length - a.length) + const alt = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + return new RegExp(`(\\b(?:${alt})\\b)`, 'g') +} + +// renderLogText splits a line on card names and wraps each match in a hoverable +// reference, leaving the rest as plain text. +function renderLogText( + text: string, + lookup: Map | undefined, + regex: RegExp | null, +): ReactNode { + if (!regex || !lookup) return text + return text.split(regex).map((part, i) => { + const card = lookup.get(part) + return card ? : part + }) +} + // EventLog is the persistent, human-readable narration of the game, shown in // every phase. Shop/prep entries come from the server; battle entries are fed // in step-by-step so stepping back through the replay walks back up the log. -export function EventLog({ entries, youSeat, battleLines }: Props) { +export function EventLog({ entries, youSeat, battleLines, cardLookup }: Props) { const [open, setOpen] = useState(true) const bodyRef = useRef(null) const lastSeq = entries.length ? entries[entries.length - 1].seq : 0 const battleLen = battleLines?.length ?? 0 + const nameRegex = useMemo(() => buildNameRegex(cardLookup), [cardLookup]) // Keep the newest line in view as the log grows or the replay advances. useEffect(() => { @@ -97,7 +144,7 @@ export function EventLog({ entries, youSeat, battleLines }: Props) { {entries.map((e) => (
{e.icon && {e.icon}} - {e.text} + {renderLogText(e.text, cardLookup, nameRegex)}
))} {battleLen > 0 && ( @@ -117,7 +164,7 @@ export function EventLog({ entries, youSeat, battleLines }: Props) { .join(' ')} > {l.icon && {l.icon}} - {l.text} + {renderLogText(l.text, cardLookup, nameRegex)}
))} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index b144dd3..dd661e1 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import { useGame } from '../useGame' -import type { Session } from '../types' +import { useCatalog } from '../useCatalog' +import type { Card, Session } from '../types' import { Lobby } from './Lobby' import { ShopPhase } from './ShopPhase' import { ArrangePhase } from './ArrangePhase' @@ -65,6 +66,27 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v return lines }, [view, inBattle, events, step, battleDone, battleRound]) + // Map every card name we know about to a representative card, so the event + // log can preview pets/foods it mentions on hover. The catalog covers all + // buyable pets and foods for the pack (even ones not currently in view); + // concrete cards from the view fill in tokens and summons (Bee, Apple, …) + // that never appear in the shop. + const catalog = useCatalog(view?.pack) + const cardLookup = useMemo(() => { + const map = new Map() + for (const c of catalog) if (c.name) map.set(c.name, c) + const add = (c?: Card) => { + if (c?.name && !map.has(c.name)) map.set(c.name, c) + } + if (view) { + view.shopRow?.forEach(add) + view.players?.forEach((p) => p.deck?.forEach(add)) + view.battle?.lineups?.forEach((line) => line.forEach(add)) + view.battle?.events?.forEach((ev) => add(ev.card)) + } + return map + }, [catalog, view]) + if (!view) { return (
@@ -148,7 +170,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v {view.phase === 'gameover' && } {view.phase !== 'lobby' && ( - + )}
diff --git a/web/src/styles.css b/web/src/styles.css index bcd78ce..58bf770 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -601,6 +601,21 @@ h3 { word-break: break-word; } +/* Card names embedded in a log line: a subtle dotted underline hints they can + be hovered to preview the card. */ +.log-card-ref { + text-decoration: underline dotted rgba(253, 243, 220, 0.45); + text-underline-offset: 2px; + cursor: help; + font-weight: 600; + color: var(--cream); +} + +.log-card-ref:hover { + text-decoration-color: var(--gold); + color: var(--gold); +} + .log-line.log-you { border-left-color: var(--gold); } @@ -2456,6 +2471,12 @@ h3 { animation: magnify-in 90ms ease-out; } +/* Right-anchored variant: scale outward from the top-right so the preview + grows leftward and stays on-screen when hovered near the right edge. */ +.card-magnify-flip { + transform-origin: top right; +} + /* The magnifier exists to be read, so let the preview card grow to fit its full effect text instead of clipping like a fixed-height card. */ .card-magnify .card { diff --git a/web/src/useCatalog.ts b/web/src/useCatalog.ts new file mode 100644 index 0000000..6d44ec1 --- /dev/null +++ b/web/src/useCatalog.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react' +import { fetchCatalog } from './api' +import type { Card } from './types' + +// useCatalog loads the representative card for every pet and food in a pack and +// caches the result per pack. It's used to look up cards by name (e.g. to +// preview pets mentioned in the event log), even ones not currently in view. +export function useCatalog(pack?: string): Card[] { + const [cards, setCards] = useState([]) + useEffect(() => { + if (!pack) return + let cancelled = false + fetchCatalog(pack) + .then((c) => { + if (!cancelled) setCards(c) + }) + .catch(() => { + /* the log simply stays non-hoverable if the catalog can't load */ + }) + return () => { + cancelled = true + } + }, [pack]) + return cards +} diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index ca90d72..462149b 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/anim.ts","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useGame.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/DiceRoll.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/anim.ts","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useCatalog.ts","./src/useGame.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/DiceRoll.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"} \ No newline at end of file