Various UX improvements.
This commit is contained in:
@@ -30,6 +30,9 @@ interface SideVis {
|
|||||||
pending: Card[] // foods revealed (or prepped) waiting for a pet
|
pending: Card[] // foods revealed (or prepped) waiting for a pet
|
||||||
unit: UnitVis | null
|
unit: UnitVis | null
|
||||||
setAside: Card[] // fainted pets kept beside the arena with a pending effect
|
setAside: Card[] // fainted pets kept beside the arena with a pending effect
|
||||||
|
// Ids of cards (set-aside pets or spent food perks) released this step: kept
|
||||||
|
// in their arrays for one beat so they can animate out, cleared next event.
|
||||||
|
leaving: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Milliseconds each event type stays on screen during playback.
|
// Milliseconds each event type stays on screen during playback.
|
||||||
@@ -57,10 +60,24 @@ const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').le
|
|||||||
// seat's visual state. Units that died in the last applied event are still
|
// seat's visual state. Units that died in the last applied event are still
|
||||||
// present with dying=true so they can animate out.
|
// present with dying=true so they can animate out.
|
||||||
function replay(events: BattleEvent[], stackSizes: number[], upto: number): SideVis[] {
|
function replay(events: BattleEvent[], stackSizes: number[], upto: number): SideVis[] {
|
||||||
const sides: SideVis[] = stackSizes.map((n) => ({ stack: n, pending: [], unit: null, setAside: [] }))
|
const sides: SideVis[] = stackSizes.map((n) => ({
|
||||||
|
stack: n,
|
||||||
|
pending: [],
|
||||||
|
unit: null,
|
||||||
|
setAside: [],
|
||||||
|
leaving: [],
|
||||||
|
}))
|
||||||
for (let k = 0; k < upto && k < events.length; k++) {
|
for (let k = 0; k < upto && k < events.length; k++) {
|
||||||
for (const s of sides) {
|
for (const s of sides) {
|
||||||
if (s.unit?.dying) s.unit = null // clear last step's casualties
|
if (s.unit?.dying) s.unit = null // clear last step's casualties
|
||||||
|
if (s.leaving.length) {
|
||||||
|
// Drop cards that finished animating out last step.
|
||||||
|
const gone = new Set(s.leaving)
|
||||||
|
s.setAside = s.setAside.filter((c) => !gone.has(c.id))
|
||||||
|
if (s.unit) s.unit.foods = s.unit.foods.filter((c) => !gone.has(c.id))
|
||||||
|
s.pending = s.pending.filter((c) => !gone.has(c.id))
|
||||||
|
s.leaving = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const ev = events[k]
|
const ev = events[k]
|
||||||
switch (ev.type) {
|
switch (ev.type) {
|
||||||
@@ -152,12 +169,10 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
|
|||||||
break
|
break
|
||||||
case 'release': {
|
case 'release': {
|
||||||
// A spent card leaves the play area: a set-aside pet (Turtle) or a
|
// A spent card leaves the play area: a set-aside pet (Turtle) or a
|
||||||
// used-up food perk (Melon), which lives in the pet's food fan.
|
// used-up food perk (Melon), which lives in the pet's food fan. Flag it
|
||||||
const s = sides[ev.seat!]
|
// rather than removing it, so it animates out before unmounting.
|
||||||
const id = ev.card?.id
|
const id = ev.card?.id
|
||||||
s.setAside = s.setAside.filter((c) => c.id !== id)
|
if (id) sides[ev.seat!].leaving.push(id)
|
||||||
if (s.unit) s.unit.foods = s.unit.foods.filter((c) => c.id !== id)
|
|
||||||
s.pending = s.pending.filter((c) => c.id !== id)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,7 +342,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
const setAsideEl = s.setAside.length > 0 && (
|
const setAsideEl = s.setAside.length > 0 && (
|
||||||
<div className="setaside-row">
|
<div className="setaside-row">
|
||||||
{s.setAside.map((c) => (
|
{s.setAside.map((c) => (
|
||||||
<CardView key={c.id} card={c} size="sm" />
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`setaside-card ${s.leaving.includes(c.id) ? 'is-leaving' : ''}`}
|
||||||
|
>
|
||||||
|
<CardView card={c} size="sm" />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -339,7 +359,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
const foodFanEl = foods.length > 0 && (
|
const foodFanEl = foods.length > 0 && (
|
||||||
<div className="food-fan">
|
<div className="food-fan">
|
||||||
{foods.map((f, i) => (
|
{foods.map((f, i) => (
|
||||||
<div key={f.id} className="food-fan-card" style={{ zIndex: i + 1 }}>
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className={`food-fan-card ${s.leaving.includes(f.id) ? 'is-leaving' : ''}`}
|
||||||
|
style={{ zIndex: i + 1 }}
|
||||||
|
>
|
||||||
<CardView card={f} size="sm" />
|
<CardView card={f} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,35 @@
|
|||||||
import { useState } from 'react'
|
import { useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import type { Card } from '../types'
|
import type { Card } from '../types'
|
||||||
import { artFor } from '../petArt'
|
import { artFor } from '../petArt'
|
||||||
|
|
||||||
|
// useFitText shrinks the effect text until it fits its band, so long abilities
|
||||||
|
// (and the smaller battle cards) render in full instead of being clipped by
|
||||||
|
// the fixed card height. It never grows past the CSS size, only shrinks toward
|
||||||
|
// a small floor, and re-fits when the band resizes (breakpoints, battle mode).
|
||||||
|
function useFitText(dep: unknown) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
const fit = () => {
|
||||||
|
el.style.fontSize = ''
|
||||||
|
let size = parseFloat(getComputedStyle(el).fontSize)
|
||||||
|
const min = 6
|
||||||
|
// Guard the loop; each step is 0.5px so ~40 covers any realistic band.
|
||||||
|
for (let i = 0; i < 40 && el.scrollHeight > el.clientHeight && size > min; i++) {
|
||||||
|
size = Math.max(min, size - 0.5)
|
||||||
|
el.style.fontSize = `${size}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fit()
|
||||||
|
const ro = new ResizeObserver(fit)
|
||||||
|
ro.observe(el)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [dep])
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
card: Card
|
card: Card
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
@@ -39,6 +66,9 @@ export function CardView({
|
|||||||
// preview copy itself opts out so it can't recurse.
|
// preview copy itself opts out so it can't recurse.
|
||||||
const [hover, setHover] = useState<{ x: number; y: number } | null>(null)
|
const [hover, setHover] = useState<{ x: number; y: number } | null>(null)
|
||||||
const power = (card.power ?? 0) + bonus
|
const power = (card.power ?? 0) + bonus
|
||||||
|
// Shrink long ability text to fit the (fixed-height) card, re-fitting when
|
||||||
|
// the text or card size changes. Only the branch that renders attaches it.
|
||||||
|
const effectRef = useFitText(`${size}|${card.effectText ?? ''}|${card.food ?? ''}`)
|
||||||
const classes = [
|
const classes = [
|
||||||
'card',
|
'card',
|
||||||
`card-${size}`,
|
`card-${size}`,
|
||||||
@@ -89,14 +119,18 @@ export function CardView({
|
|||||||
<div className="card-name">{card.name}</div>
|
<div className="card-name">{card.name}</div>
|
||||||
{card.kind === 'pet' ? (
|
{card.kind === 'pet' ? (
|
||||||
<>
|
<>
|
||||||
{card.effectText && <div className="card-effect">{card.effectText}</div>}
|
{card.effectText && (
|
||||||
|
<div className="card-effect" ref={effectRef}>
|
||||||
|
{card.effectText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="card-bottom">
|
<div className="card-bottom">
|
||||||
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
|
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
|
||||||
{damage > 0 && !dead && <span className="card-damage">−{damage}</span>}
|
{damage > 0 && !dead && <span className="card-damage">−{damage}</span>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="card-effect">
|
<div className="card-effect" ref={effectRef}>
|
||||||
{card.effectText ?? (card.food === 'apple' ? '+1 power (this battle)' : '')}
|
{card.effectText ?? (card.food === 'apple' ? '+1 power (this battle)' : '')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+34
-4
@@ -854,9 +854,10 @@ h3 {
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1.5px solid rgba(0, 0, 0, 0.3);
|
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
/* Inset ring instead of a real border — see .card-power. */
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
inset 0 0 0 1.5px rgba(0, 0, 0, 0.3),
|
||||||
inset 0 1.5px 1px rgba(255, 255, 255, 0.6),
|
inset 0 1.5px 1px rgba(255, 255, 255, 0.6),
|
||||||
inset 0 -2px 2px rgba(0, 0, 0, 0.25);
|
inset 0 -2px 2px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
@@ -879,14 +880,17 @@ h3 {
|
|||||||
background: radial-gradient(circle at 35% 28%, #ffcf6b, var(--coral-dark));
|
background: radial-gradient(circle at 35% 28%, #ffcf6b, var(--coral-dark));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 0 1px 2px rgba(120, 45, 5, 0.55);
|
text-shadow: 0 1px 2px rgba(120, 45, 5, 0.55);
|
||||||
border: 2px solid rgba(120, 45, 5, 0.35);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 31px;
|
width: 31px;
|
||||||
height: 31px;
|
height: 31px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 0.98rem;
|
font-size: 0.98rem;
|
||||||
|
/* The rim is drawn with an inset ring rather than a real border: a
|
||||||
|
border-radius:50% element with a semi-transparent border renders a
|
||||||
|
visible seam at its 6 o'clock point in Chromium (scale-invariant). */
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
inset 0 0 0 2px rgba(120, 45, 5, 0.35),
|
||||||
inset 0 1.5px 1px rgba(255, 255, 255, 0.55),
|
inset 0 1.5px 1px rgba(255, 255, 255, 0.55),
|
||||||
inset 0 -2px 3px rgba(120, 45, 5, 0.4),
|
inset 0 -2px 3px rgba(120, 45, 5, 0.4),
|
||||||
0 2px 3px rgba(0, 0, 0, 0.3);
|
0 2px 3px rgba(0, 0, 0, 0.3);
|
||||||
@@ -894,9 +898,9 @@ h3 {
|
|||||||
|
|
||||||
.card-power.is-buffed {
|
.card-power.is-buffed {
|
||||||
background: radial-gradient(circle at 35% 28%, #9ff0af, #1f9e55);
|
background: radial-gradient(circle at 35% 28%, #9ff0af, #1f9e55);
|
||||||
border-color: rgba(15, 80, 40, 0.4);
|
|
||||||
text-shadow: 0 1px 2px rgba(15, 60, 30, 0.55);
|
text-shadow: 0 1px 2px rgba(15, 60, 30, 0.55);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
inset 0 0 0 2px rgba(15, 80, 40, 0.4),
|
||||||
inset 0 1.5px 1px rgba(255, 255, 255, 0.55),
|
inset 0 1.5px 1px rgba(255, 255, 255, 0.55),
|
||||||
inset 0 -2px 3px rgba(15, 80, 40, 0.4),
|
inset 0 -2px 3px rgba(15, 80, 40, 0.4),
|
||||||
0 0 8px rgba(80, 220, 120, 0.5);
|
0 0 8px rgba(80, 220, 120, 0.5);
|
||||||
@@ -1434,6 +1438,22 @@ h3 {
|
|||||||
margin-top: -88px;
|
margin-top: -88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Released cards (a spent set-aside pet, or a used-up food perk) shrink and
|
||||||
|
fade out over their release step rather than vanishing instantly. The step
|
||||||
|
lasts 500ms (EVENT_MS.release), so the 400ms exit finishes before unmount. */
|
||||||
|
@keyframes card-leave {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.55) translateY(-8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setaside-card.is-leaving,
|
||||||
|
.food-fan-card.is-leaving {
|
||||||
|
animation: card-leave 400ms cubic-bezier(0.4, 0, 0.7, 0.4) forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.battle-unit {
|
.battle-unit {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1444,6 +1464,13 @@ h3 {
|
|||||||
height: 132px;
|
height: 132px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Battle cards are short, so give the ability text more of the card by
|
||||||
|
shrinking the decorative art; useFitText then keeps the text at a larger
|
||||||
|
size before it has to shrink to fit. */
|
||||||
|
.battle-unit .card-art {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes unit-reveal {
|
@keyframes unit-reveal {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -1835,7 +1862,10 @@ h3 {
|
|||||||
color: var(--cream);
|
color: var(--cream);
|
||||||
border: 2px solid var(--cocoa);
|
border: 2px solid var(--cocoa);
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-radius: 8px 0 0 8px;
|
/* The button is flipped 180° (below) to read text upward, which also flips
|
||||||
|
these corners — so round the right pair here to land rounded corners on
|
||||||
|
the inner (screen-facing) edge after the rotation. */
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user