diff --git a/README.md b/README.md index 2cc0cf4..1db5594 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,12 @@ including a character sheet or an encounter) and **Account**. items) and by level in the class that gives them. - **Armor holds Dexterity back** as in the rules: all of it counts toward AC in light armor, at most +2 in medium, none in heavy. +- **Battles that keep to the fight.** While a battle is under way the scene + description shows only between rounds, cards' details start closed (the + spotlight, or on a phone the card on turn, has them), and the DM's setup, + Experience and Rewards panels fold to their titles. Button labels wrap + inside their buttons, and on a phone quick stats no longer sits on Log out + nor incoming rolls on the bottom bar. - **Stricter battles.** A spell with no slot of its level or higher left cannot be cast (cantrips never need one), and a player changes the weapon in hand only as the battle begins or on their own turn after attacking. diff --git a/backend/package.json b/backend/package.json index 4c33d8f..7421f04 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.31.0", + "version": "1.32.0", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/docs/ENCOUNTERS.md b/docs/ENCOUNTERS.md index 4ef5313..4422768 100644 --- a/docs/ENCOUNTERS.md +++ b/docs/ENCOUNTERS.md @@ -155,6 +155,16 @@ characters, and characters keep their current HP. Both save when the DM clicks out of the box. +In a battle under way the Scene box is only there between rounds: while the +first in the turn order is on turn. For the rest of the round it is out of +the way, for the DM and the players alike. Before the battle begins and once +it is over it is always there. + +**Folded panels.** While a battle is in progress, "Add to the scene", +Experience and Rewards on the DM's side column start folded to their title +(click the title to open one), so the fight is what fills the screen. They +open again when the battle ends, or before it begins. + ## 5. Who is in the scene The DM adds people from the "Add to the scene" panel. @@ -240,7 +250,12 @@ Each has a card. The card's left edge is coloured by kind, and it shows: - **NPCs:** role, attitude and description; for the DM also AC, attack, damage and DM notes. -Details start open for the DM and closed for players. Weapons and spells +In a battle every card's details start closed, so the list stays short: on a +wide screen the spotlight shows whoever is on turn in full, and on a narrower +one the card of whoever is on turn opens its details by itself (and closes +them again when the turn passes). In a quest or free roam the details start +open for the DM and closed for players. "▸ Show details" / "▾ Hide details" +on a card overrides this for that card. Weapons and spells above a character's level are left out, because the character cannot use them. diff --git a/frontend/package.json b/frontend/package.json index 8946b61..3977e15 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.31.0", + "version": "1.32.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index e429cb5..067e4ad 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { Suspense } from 'react'; +import { Suspense, useEffect, useRef } from 'react'; import { Link, NavLink, Outlet, useMatch } from 'react-router-dom'; import { useAuth } from '../auth/useAuth'; import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign'; @@ -15,6 +15,19 @@ export function Layout() { useRememberCampaign(onCampaign?.params.campaignId); const campaignId = useLastCampaignId(); + // How tall the top bar really is, as --topbar-h, so that what sticks under + // it (the turn order in a battle) sits right below it on any screen. + const topbar = useRef(null); + useEffect(() => { + const bar = topbar.current; + if (!bar) return; + const measure = () => document.documentElement.style.setProperty('--topbar-h', `${bar.offsetHeight}px`); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(bar); + return () => observer.disconnect(); + }, []); + // The same destinations twice: along the top bar on a wide screen, and as a // bar along the bottom, in reach of a thumb, on a phone. const destinations = [ @@ -39,7 +52,7 @@ export function Layout() { return (
-
+
⚄ diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..0c6bf46 --- /dev/null +++ b/frontend/src/hooks/useMediaQuery.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +// Whether a CSS media query matches right now, kept up to date as the window +// is resized: useMediaQuery('(min-width: 960px)'). +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => window.matchMedia(query).matches); + useEffect(() => { + const list = window.matchMedia(query); + const onChange = () => setMatches(list.matches); + onChange(); + list.addEventListener('change', onChange); + return () => list.removeEventListener('change', onChange); + }, [query]); + return matches; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index dec4f7c..9b8936e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -25,6 +25,8 @@ --radius: 8px; --font-display: 'Cinzel', Georgia, serif; --font-body: 'Crimson Pro', Georgia, 'Times New Roman', serif; + /* The top bar's height, measured by Layout.tsx; this is only until then. */ + --topbar-h: 3.6rem; } @media (prefers-color-scheme: light) { @@ -262,7 +264,12 @@ textarea { color: var(--text); cursor: pointer; transition: all 0.15s ease; - white-space: nowrap; + /* A label longer than the room there is wraps inside the button rather + than running out of it (or under the button beside it). */ + max-width: 100%; + line-height: 1.25; + text-align: center; + overflow-wrap: break-word; } .btn:hover:not(:disabled) { border-color: var(--accent); @@ -883,7 +890,7 @@ input[type='radio'] { /* Who acts when, as one line of names: this round, a flag, the next round. */ .initiative-track { position: sticky; - top: 3.6rem; + top: var(--topbar-h); z-index: 25; display: flex; align-items: center; @@ -2176,16 +2183,26 @@ textarea { .fab-dice:hover { transform: none; } + .dice-incoming { + left: 0.85rem; + bottom: calc(var(--bottom-nav-height) + 0.6rem); + width: calc(100vw - 1.7rem); + } .dice-panel { left: 0.85rem; bottom: calc(var(--bottom-nav-height) + 0.6rem); width: calc(100vw - 1.7rem); max-height: calc(100dvh - var(--bottom-nav-height) - 5rem); } + /* Log out has a set width on a phone, so quick stats can sit just left of it. */ + .topbar-user .btn { + width: 5.2rem; + padding: 0.45rem 0.3rem; + } .fab-quickstats { - top: 0.3rem; + top: calc((var(--topbar-h) - 40px) / 2); bottom: auto; - right: 6.4rem; + right: calc(0.85rem + 5.2rem + 0.5rem); z-index: 31; width: 40px; height: 40px; @@ -2194,7 +2211,7 @@ textarea { } /* The brand makes room for it. */ .app-shell:has(.fab-quickstats) .topbar-brand { - max-width: calc(100vw - 11rem); + max-width: calc(100vw - 0.85rem - 6.55rem - 40px - 1.25rem); } .drawer { width: 100vw; @@ -2237,7 +2254,6 @@ textarea { min-width: min(100%, 8rem) !important; } .btn { - white-space: normal; padding: 0.6rem 0.9rem; } .grid-cols-2 { @@ -2289,11 +2305,6 @@ textarea { .jump-link { padding: 0.5rem 0.8rem; } - .attack-bar { - top: 3.4rem; - max-height: 55dvh; - overflow-y: auto; - } [data-jump] { scroll-margin-top: 3.8rem; } @@ -2748,7 +2759,7 @@ button.slot-pip:disabled { /* Everyone in the scene on one line that stays under the top bar. */ .turn-strip { position: sticky; - top: 3.6rem; + top: var(--topbar-h); z-index: 25; display: flex; gap: 0.4rem; @@ -2813,20 +2824,17 @@ button.slot-pip:disabled { .turn-chip.has-acted { opacity: 0.55; } -/* The attack bar sits under the strip, not behind it. */ -.attack-bar { - top: 7.6rem; -} - /* On a wide screen the side column stays in view, scrolling on its own. */ @media (min-width: 960px) { .encounter-side { position: sticky; - top: 7.6rem; - max-height: calc(100vh - 8.4rem); + top: calc(var(--topbar-h) + 4rem); + max-height: calc(100vh - var(--topbar-h) - 4.8rem); overflow-y: auto; scrollbar-width: thin; padding-right: 2px; + /* Room to scroll the last card up past the quick-stats button. */ + padding-bottom: 5rem; } } .spotlight { @@ -2886,6 +2894,7 @@ button.slot-pip:disabled { summary.card-title { cursor: pointer; list-style: none; + justify-content: flex-start; } summary.card-title::-webkit-details-marker { display: none; @@ -2902,15 +2911,6 @@ details:not([open]) > summary.card-title::after { content: '▸'; } -@media (max-width: 900px) { - .turn-strip { - top: 2.9rem; - } - .attack-bar { - top: 6.6rem; - } -} - /* In one column the card on turn is already at the top of the list. */ @media (max-width: 959px) { .spotlight, diff --git a/frontend/src/pages/EncounterPage.tsx b/frontend/src/pages/EncounterPage.tsx index 73c2a8e..9b18244 100644 --- a/frontend/src/pages/EncounterPage.tsx +++ b/frontend/src/pages/EncounterPage.tsx @@ -45,6 +45,7 @@ import { GearInForce } from '../components/CharacterPanel'; import { SheetNumbers } from '../components/SheetNumbers'; import { ConditionChip, ConditionPicker, ResourceTracker, StateBadges } from '../components/CharacterState'; import { useHotkeys } from '../hooks/useHotkeys'; +import { useMediaQuery } from '../hooks/useMediaQuery'; import { Rollable } from '../components/Rollable'; import { GroupCheckModal } from '../components/GroupCheckModal'; import { withBonus } from '../lib/diceRoller'; @@ -496,6 +497,7 @@ function CombatantCard({ onTarget, isPinned, onPin, + detailsOpen, onRemove, onError, }: { @@ -519,17 +521,18 @@ function CombatantCard({ // Whether this combatant's stats are the ones kept in view, and the switch for it. isPinned: boolean; onPin: () => void; + // Whether the details start open (until the viewer opens or closes them). + detailsOpen: boolean; onRemove: () => void; onError: (err: unknown) => void; }) { const [hpDelta, setHpDelta] = useState(''); // How many rounds the next condition added lasts; empty for until removed. const [rounds, setRounds] = useState(''); - // Details start open for the DM (who runs the scene from this screen) and - // closed for players; null = "not toggled yet", so it follows isDM once the - // campaign role has loaded. + // null = "not toggled yet", so the details follow detailsOpen (which + // changes as the turn passes, and once the campaign role has loaded). const [detailsToggled, setDetailsToggled] = useState(null); - const showDetails = detailsToggled ?? isDM; + const showDetails = detailsToggled ?? detailsOpen; const info = p.characterCombatInfo; const monster = p.monster; @@ -987,12 +990,14 @@ function ExperiencePanel({ difficulty, xpAwarded, partySize, + folded, onError, }: { encounterId: string; difficulty: EncounterDifficulty; xpAwarded: number | null; partySize: number; + folded: boolean; // starts closed, as it does while a battle is under way onError: (err: unknown) => void; }) { const awardXp = useAwardXp(encounterId); @@ -1001,11 +1006,11 @@ function ExperiencePanel({ const { thresholds, rating } = difficulty; return ( -
-
+
+ Experience {rating && {RATING_LABELS[rating]}} -
+ {difficulty.monsters === 0 ? (

No monster here has a challenge rating, so there is nothing to judge the difficulty by. XP can still be awarded: type an amount. @@ -1060,7 +1065,7 @@ function ExperiencePanel({

)} -
+ ); } @@ -1076,6 +1081,7 @@ function RewardsPanel({ characters, weapons, items, + folded, onError, }: { encounter: Encounter; @@ -1083,6 +1089,7 @@ function RewardsPanel({ characters: CharacterSummary[]; weapons: Weapon[]; items: Item[]; + folded: boolean; // starts closed, as it does while a battle is under way onError: (err: unknown) => void; }) { const addReward = useAddReward(encounter.id); @@ -1130,8 +1137,8 @@ function RewardsPanel({ } return ( -
-
Rewards
+
+ Rewards{rewards.length > 0 && {rewards.length}} {isDM && rewards.length === 0 && (

Line up coins, weapons and items here. Players only see a reward once you award it. @@ -1284,7 +1291,7 @@ function RewardsPanel({

)} - + ); } @@ -1319,6 +1326,8 @@ export function EncounterPage() { const setArchived = useSetEncounterArchived(encounter?.campaignId ?? ''); const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? ''); const navigate = useNavigate(); + // Wide enough for the side column, and so for the spotlight in it. + const wide = useMediaQuery('(min-width: 960px)'); const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character'); const [pickId, setPickId] = useState(''); @@ -1454,6 +1463,17 @@ export function EncounterPage() { ); const unrolled = participants.filter((p) => p.kind !== 'CHARACTER' && p.initiativeRoll == null).length; + // In a battle every card's details start closed, so the list stays short: + // the spotlight beside it shows whoever is on turn in full, and where there + // is no room for the spotlight (one column) their own card opens instead. + // Elsewhere the DM, who runs the scene from this screen, gets them open. + const detailsOpen = (p: EncounterParticipant) => (isBattle ? p.id === active?.id && !wide : isDM); + // A battle under way folds away what is for before and after it. + const inBattle = isBattle && encounter.status === 'ACTIVE'; + // The scene is set before the fight and between rounds (while the first in + // the order is on turn), and stays out of the way during the round. + const showScene = !inBattle || active?.id === standing[0]?.id; + const card = (p: EncounterParticipant) => ( setPinnedId(p.id === pinnedId ? null : p.id)} + detailsOpen={detailsOpen(p)} onRemove={() => removeParticipant.mutate(p.id, fail)} onError={onError} /> @@ -1655,7 +1676,7 @@ export function EncounterPage() { )} - {(isDM || encounter.description) && ( + {(isDM || encounter.description) && showScene && (
{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}
{isDM ? ( @@ -1790,8 +1811,8 @@ export function EncounterPage() { /> )} {isDM && ( -
-
Add to the scene
+
+ Add to the scene