From 3b75ec9b3df3831b7fe5702c68cf213f91633733 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:38:33 -0400 Subject: [PATCH] Animate player display inactive transitions and death state Player display now tracks displayParticipants separately from raw encounter participants so active->inactive can animate out before removal, and inactive-> active can animate in. This preserves 1-list order, keeps DM display behavior unchanged, and avoids fading dying/stable participants. PlayerParticipantCard handles transitions: - active->inactive: scale/slide/fade out, then parent removes from display list - inactive->active: scale/slide/fade in - alive->dead: keep visible with dim/desaturate/red-rim death cue + skull pulse - dying/stable: full visible, no fade/removal DisplayView inactive characterization updated to wait for exit animation before asserting removal. TODO item removed. --- TODO.md | 12 -- src/App.js | 105 ++++++++++++++++-- .../DisplayView.characterization.test.js | 5 +- 3 files changed, 97 insertions(+), 25 deletions(-) diff --git a/TODO.md b/TODO.md index ea91014..0ce3028 100644 --- a/TODO.md +++ b/TODO.md @@ -11,18 +11,6 @@ not sure good way to do this -### FEAT: player display fade transitions for inactive state -- Inactive is DM-triggered via Mark Inactive. -- Player display should fade inactive participant out, then remove from display list. -- Reactivating should fade participant in. -- DM display keeps inactive participant visible. -- Dead state does not imply inactive/disabled. -- Dying/stable must not fade out or leave layout holes. -- Dead can keep skull/Dead label; create some good visual cues, no removal - but a cool transition to death would be nice. - - - - ### TEST GAP: current branch changes need focused coverage - Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`. diff --git a/src/App.js b/src/App.js index f871e2d..a58a731 100644 --- a/src/App.js +++ b/src/App.js @@ -2554,6 +2554,61 @@ function AdminView({ userId }) { // DISPLAY VIEW COMPONENT (Player View) // ============================================================================ +// Player participant card: animate state transitions. +// mount/activate : animate in (opacity+scale+slide) +// deactivate : animate out then signal exit +// alive→dead : transition to death cue (dim+desaturate+skull pulse+red rim) +// conscious/dying/stable: full visible, no fade +function PlayerParticipantCard({ id, isActive, status, onExit, className, children, divRef }) { + // phase: 'hidden' (opacity-0, start state) -> 'visible' (opacity-100) + // disable: 'visible' -> 'exiting' (opacity-0, transition plays) -> onExit + const [phase, setPhase] = useState('hidden'); + const isDead = status === 'dead'; + + useEffect(() => { + if (!isActive) { + // ensure current 'visible' frame painted, THEN flip to exiting so + // browser sees opacity change and plays the fade-out. + const raf = requestAnimationFrame(() => { + setPhase('exiting'); + }); + const t = setTimeout(() => onExit(id), 1000); + return () => { cancelAnimationFrame(raf); clearTimeout(t); }; + } + // enable/reactivate: paint hidden frame first, THEN flip to visible + // so browser sees opacity change and plays the transition. + setPhase('hidden'); + const raf = requestAnimationFrame(() => { + requestAnimationFrame(() => setPhase('visible')); + }); + return () => cancelAnimationFrame(raf); + }, [isActive, id, onExit]); + + // animate: opacity + scale + slide. Visible motion, not just fade. + const fadeClass = phase === 'visible' + ? 'opacity-100 scale-100 translate-y-0' + : 'opacity-0 scale-75 translate-y-4'; + + // death transition: fires once when alive→dead. dim + desaturate + red rim. + const deathClass = isDead + ? 'brightness-50 saturate-0 border-2 border-red-900/70 shadow-red-900/50 shadow-2xl' + : ''; + + return ( +
+ {children} + {isDead && ( +
+ ☠️ +
+ )} +
+ ); +} + function DisplayView() { const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument( getPath.activeDisplay() @@ -2566,9 +2621,37 @@ function DisplayView() { const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [wakeLockEnabled, setWakeLockEnabled] = useState(false); + const [displayParticipants, setDisplayParticipants] = useState([]); const wakeLockRef = useRef(null); const currentParticipantRef = useRef(null); + // Player display transition state. Active participants render normally. + // Active→inactive: keep prior card, mark __displayActive=false, animate out, + // then PlayerParticipantCard calls handleExit to remove from display list. + // Inactive→active: card reappears with __displayActive=true and fades in. + useEffect(() => { + const source = (activeEncounterData && activeEncounterData.participants) || []; + setDisplayParticipants(prev => { + const prevById = new Map(prev.map(p => [p.id, p])); + const out = []; + for (const p of source) { + const prevP = prevById.get(p.id); + if (p.isActive !== false) { + out.push({ ...p, __displayActive: true }); + } else if (prevP && prevP.__displayActive !== false) { + out.push({ ...p, __displayActive: false }); + } else if (prevP && prevP.__displayActive === false) { + out.push(prevP); + } + } + return out; + }); + }, [activeEncounterData]); + + const handleExit = useCallback((id) => { + setDisplayParticipants(prev => prev.filter(p => p.id !== id)); + }, []); + useEffect(() => { const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFsChange); @@ -2706,13 +2789,9 @@ function DisplayView() { const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hideNpcHp = activeDisplayData?.hideNpcHp ?? false; - let participantsToRender = []; - if (participants) { - // 1-list model: participants[] IS the display order (DM drag = source of - // truth). Do NOT re-sort by initiative — that diverges from AdminView / - // turnOrderIds after any cross-init drag (BUG-15). - participantsToRender = participants.filter(p => p.isActive !== false); - } + // 1-list model: displayParticipants IS the display order (participants[] order + // plus temporary exiting cards). Do NOT re-sort by initiative. + const participantsToRender = displayParticipants; const displayStyles = campaignBackgroundUrl ? { @@ -2783,10 +2862,14 @@ function DisplayView() { } return ( -

(Inactive)

)} -

+ ); })}
diff --git a/src/tests/DisplayView.characterization.test.js b/src/tests/DisplayView.characterization.test.js index 842b096..c367bca 100644 --- a/src/tests/DisplayView.characterization.test.js +++ b/src/tests/DisplayView.characterization.test.js @@ -94,7 +94,7 @@ describe('DisplayView characterization', () => { expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0); }); - test('DisplayView hides inactive participants for all types', async () => { + test('DisplayView hides inactive participants for all types after fade-out', async () => { seedActiveDisplay([ { ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true }, { ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false }, @@ -103,7 +103,8 @@ describe('DisplayView characterization', () => { render(); await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument()); - expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(); + // inactive held during exit animation, removed after transition + await waitFor(() => expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(), { timeout: 1500 }); expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument(); }); });