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