Add ability to hover pets in event log.
This commit is contained in:
@@ -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?
|
|
||||||
@@ -82,6 +82,62 @@ function triggersOf(text?: string): string[] {
|
|||||||
return out
|
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 (
|
||||||
|
<div className="trigger-gloss">
|
||||||
|
{trigs.map((t) => (
|
||||||
|
<div className="trigger-gloss-line" key={t}>
|
||||||
|
<strong>{t}</strong> — {TRIGGER_GLOSS[t]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<div
|
||||||
|
className={`card-magnify ${flip ? 'card-magnify-flip' : ''}`}
|
||||||
|
style={
|
||||||
|
flip
|
||||||
|
? { right: Math.max(8, window.innerWidth - x + 24), top }
|
||||||
|
: { left: Math.min(x + 24, window.innerWidth - 200), top }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardView card={card} size="lg" bonus={bonus} damage={damage} dead={dead} preview />
|
||||||
|
<TriggerGloss card={card} />
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
card: Card
|
card: Card
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
@@ -135,33 +191,9 @@ export function CardView({
|
|||||||
|
|
||||||
// Place the magnified preview near the cursor, clamped into the viewport.
|
// Place the magnified preview near the cursor, clamped into the viewport.
|
||||||
const previewEl =
|
const previewEl =
|
||||||
hover && !preview && !noMagnify
|
hover && !preview && !noMagnify ? (
|
||||||
? createPortal(
|
<CardMagnify card={card} x={hover.x} y={hover.y} bonus={bonus} damage={damage} dead={dead} />
|
||||||
<div
|
) : null
|
||||||
className="card-magnify"
|
|
||||||
style={{
|
|
||||||
left: Math.min(hover.x + 24, window.innerWidth - 200),
|
|
||||||
top: Math.min(Math.max(hover.y - 120, 8), window.innerHeight - 320),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardView card={card} size="lg" bonus={bonus} damage={damage} dead={dead} preview />
|
|
||||||
{(() => {
|
|
||||||
const trigs = triggersOf(card.effectText).filter((t) => TRIGGER_GLOSS[t])
|
|
||||||
if (trigs.length === 0) return null
|
|
||||||
return (
|
|
||||||
<div className="trigger-gloss">
|
|
||||||
{trigs.map((t) => (
|
|
||||||
<div className="trigger-gloss-line" key={t}>
|
|
||||||
<strong>{t}</strong> — {TRIGGER_GLOSS[t]}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { BattleEvent, LogEntry } from '../types'
|
import type { ReactNode } from 'react'
|
||||||
|
import type { BattleEvent, Card, LogEntry } from '../types'
|
||||||
|
import { CardMagnify } from './CardView'
|
||||||
|
|
||||||
// A single battle line, derived from the battle event stream and fed in by
|
// A single battle line, derived from the battle event stream and fed in by
|
||||||
// Table so the log stays in step with the replay controls.
|
// Table so the log stays in step with the replay controls.
|
||||||
@@ -54,6 +56,9 @@ interface Props {
|
|||||||
// During a battle, the lines revealed up to the current replay step. The
|
// During a battle, the lines revealed up to the current replay step. The
|
||||||
// last one is the "current" step and gets highlighted.
|
// last one is the "current" step and gets highlighted.
|
||||||
battleLines?: BattleLogLine[]
|
battleLines?: BattleLogLine[]
|
||||||
|
// A name→card map used to make pet/food mentions in the log hoverable. When
|
||||||
|
// absent (or empty), the log renders as plain text.
|
||||||
|
cardLookup?: Map<string, Card>
|
||||||
}
|
}
|
||||||
|
|
||||||
function seatClass(seat: number, youSeat: number): string {
|
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'
|
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 (
|
||||||
|
<span
|
||||||
|
className="log-card-ref"
|
||||||
|
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY })}
|
||||||
|
onMouseMove={(e) => setHover({ x: e.clientX, y: e.clientY })}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
{hover && <CardMagnify card={card} x={hover.x} y={hover.y} flip />}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, Card>): 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<string, Card> | undefined,
|
||||||
|
regex: RegExp | null,
|
||||||
|
): ReactNode {
|
||||||
|
if (!regex || !lookup) return text
|
||||||
|
return text.split(regex).map((part, i) => {
|
||||||
|
const card = lookup.get(part)
|
||||||
|
return card ? <LogCardRef key={i} name={part} card={card} /> : part
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// EventLog is the persistent, human-readable narration of the game, shown in
|
// 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
|
// 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.
|
// 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 [open, setOpen] = useState(true)
|
||||||
const bodyRef = useRef<HTMLDivElement>(null)
|
const bodyRef = useRef<HTMLDivElement>(null)
|
||||||
const lastSeq = entries.length ? entries[entries.length - 1].seq : 0
|
const lastSeq = entries.length ? entries[entries.length - 1].seq : 0
|
||||||
const battleLen = battleLines?.length ?? 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.
|
// Keep the newest line in view as the log grows or the replay advances.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,7 +144,7 @@ export function EventLog({ entries, youSeat, battleLines }: Props) {
|
|||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<div key={`e${e.seq}`} className={`log-line ${seatClass(e.seat, youSeat)}`}>
|
<div key={`e${e.seq}`} className={`log-line ${seatClass(e.seat, youSeat)}`}>
|
||||||
{e.icon && <span className="log-icon">{e.icon}</span>}
|
{e.icon && <span className="log-icon">{e.icon}</span>}
|
||||||
<span className="log-text">{e.text}</span>
|
<span className="log-text">{renderLogText(e.text, cardLookup, nameRegex)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{battleLen > 0 && (
|
{battleLen > 0 && (
|
||||||
@@ -117,7 +164,7 @@ export function EventLog({ entries, youSeat, battleLines }: Props) {
|
|||||||
.join(' ')}
|
.join(' ')}
|
||||||
>
|
>
|
||||||
{l.icon && <span className="log-icon">{l.icon}</span>}
|
{l.icon && <span className="log-icon">{l.icon}</span>}
|
||||||
<span className="log-text">{l.text}</span>
|
<span className="log-text">{renderLogText(l.text, cardLookup, nameRegex)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
import { useGame } from '../useGame'
|
import { useGame } from '../useGame'
|
||||||
import type { Session } from '../types'
|
import { useCatalog } from '../useCatalog'
|
||||||
|
import type { Card, Session } from '../types'
|
||||||
import { Lobby } from './Lobby'
|
import { Lobby } from './Lobby'
|
||||||
import { ShopPhase } from './ShopPhase'
|
import { ShopPhase } from './ShopPhase'
|
||||||
import { ArrangePhase } from './ArrangePhase'
|
import { ArrangePhase } from './ArrangePhase'
|
||||||
@@ -65,6 +66,27 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
|||||||
return lines
|
return lines
|
||||||
}, [view, inBattle, events, step, battleDone, battleRound])
|
}, [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<string, Card>()
|
||||||
|
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) {
|
if (!view) {
|
||||||
return (
|
return (
|
||||||
<div className="centered muted">
|
<div className="centered muted">
|
||||||
@@ -148,7 +170,12 @@ 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} youSeat={view.youSeat} battleLines={battleLines} />
|
<EventLog
|
||||||
|
entries={entries}
|
||||||
|
youSeat={view.youSeat}
|
||||||
|
battleLines={battleLines}
|
||||||
|
cardLookup={cardLookup}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -601,6 +601,21 @@ h3 {
|
|||||||
word-break: break-word;
|
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 {
|
.log-line.log-you {
|
||||||
border-left-color: var(--gold);
|
border-left-color: var(--gold);
|
||||||
}
|
}
|
||||||
@@ -2456,6 +2471,12 @@ h3 {
|
|||||||
animation: magnify-in 90ms ease-out;
|
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
|
/* 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. */
|
full effect text instead of clipping like a fixed-height card. */
|
||||||
.card-magnify .card {
|
.card-magnify .card {
|
||||||
|
|||||||
@@ -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<Card[]>([])
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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"}
|
{"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"}
|
||||||
Reference in New Issue
Block a user