V1.32.0: battles that keep to the fight
- The scene description shows during a battle only between rounds (while the first in the order is on turn), and always before and after it - Combatant details start closed in a battle: the spotlight has whoever is on turn, or on a narrow screen their own card opens - While a battle is under way, Add to the scene, Experience and Rewards fold to their titles - Button labels wrap inside their buttons instead of spilling out - Sticky turn order and side column follow the measured top bar height - Phone: quick stats no longer sits on Log out, incoming rolls clear the bottom bar, the damage bar no longer scrolls on its own - Docs updated; all packages at version 1.32.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -106,6 +106,12 @@ including a character sheet or an encounter) and **Account**.
|
|||||||
items) and by level in the class that gives them.
|
items) and by level in the class that gives them.
|
||||||
- **Armor holds Dexterity back** as in the rules: all of it counts toward AC
|
- **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.
|
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
|
- **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
|
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.
|
in hand only as the battle begins or on their own turn after attacking.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.31.0",
|
"version": "1.32.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
|
|||||||
+16
-1
@@ -155,6 +155,16 @@ characters, and characters keep their current HP.
|
|||||||
|
|
||||||
Both save when the DM clicks out of the box.
|
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
|
## 5. Who is in the scene
|
||||||
|
|
||||||
The DM adds people from the "Add to the scene" panel.
|
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,
|
- **NPCs:** role, attitude and description; for the DM also AC, attack,
|
||||||
damage and DM notes.
|
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
|
above a character's level are left out, because the character cannot use
|
||||||
them.
|
them.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.31.0",
|
"version": "1.32.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense, useEffect, useRef } from 'react';
|
||||||
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
|
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
||||||
@@ -15,6 +15,19 @@ export function Layout() {
|
|||||||
useRememberCampaign(onCampaign?.params.campaignId);
|
useRememberCampaign(onCampaign?.params.campaignId);
|
||||||
const campaignId = useLastCampaignId();
|
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<HTMLElement>(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
|
// 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.
|
// bar along the bottom, in reach of a thumb, on a phone.
|
||||||
const destinations = [
|
const destinations = [
|
||||||
@@ -39,7 +52,7 @@ export function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar" ref={topbar}>
|
||||||
<Link to="/campaigns" className="topbar-brand">
|
<Link to="/campaigns" className="topbar-brand">
|
||||||
<span className="die" aria-hidden>
|
<span className="die" aria-hidden>
|
||||||
⚄
|
⚄
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+28
-28
@@ -25,6 +25,8 @@
|
|||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--font-display: 'Cinzel', Georgia, serif;
|
--font-display: 'Cinzel', Georgia, serif;
|
||||||
--font-body: 'Crimson Pro', Georgia, 'Times New Roman', 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) {
|
@media (prefers-color-scheme: light) {
|
||||||
@@ -262,7 +264,12 @@ textarea {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
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) {
|
.btn:hover:not(:disabled) {
|
||||||
border-color: var(--accent);
|
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. */
|
/* Who acts when, as one line of names: this round, a flag, the next round. */
|
||||||
.initiative-track {
|
.initiative-track {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 3.6rem;
|
top: var(--topbar-h);
|
||||||
z-index: 25;
|
z-index: 25;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2176,16 +2183,26 @@ textarea {
|
|||||||
.fab-dice:hover {
|
.fab-dice:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
.dice-incoming {
|
||||||
|
left: 0.85rem;
|
||||||
|
bottom: calc(var(--bottom-nav-height) + 0.6rem);
|
||||||
|
width: calc(100vw - 1.7rem);
|
||||||
|
}
|
||||||
.dice-panel {
|
.dice-panel {
|
||||||
left: 0.85rem;
|
left: 0.85rem;
|
||||||
bottom: calc(var(--bottom-nav-height) + 0.6rem);
|
bottom: calc(var(--bottom-nav-height) + 0.6rem);
|
||||||
width: calc(100vw - 1.7rem);
|
width: calc(100vw - 1.7rem);
|
||||||
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
|
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 {
|
.fab-quickstats {
|
||||||
top: 0.3rem;
|
top: calc((var(--topbar-h) - 40px) / 2);
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
right: 6.4rem;
|
right: calc(0.85rem + 5.2rem + 0.5rem);
|
||||||
z-index: 31;
|
z-index: 31;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -2194,7 +2211,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
/* The brand makes room for it. */
|
/* The brand makes room for it. */
|
||||||
.app-shell:has(.fab-quickstats) .topbar-brand {
|
.app-shell:has(.fab-quickstats) .topbar-brand {
|
||||||
max-width: calc(100vw - 11rem);
|
max-width: calc(100vw - 0.85rem - 6.55rem - 40px - 1.25rem);
|
||||||
}
|
}
|
||||||
.drawer {
|
.drawer {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -2237,7 +2254,6 @@ textarea {
|
|||||||
min-width: min(100%, 8rem) !important;
|
min-width: min(100%, 8rem) !important;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
white-space: normal;
|
|
||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
}
|
}
|
||||||
.grid-cols-2 {
|
.grid-cols-2 {
|
||||||
@@ -2289,11 +2305,6 @@ textarea {
|
|||||||
.jump-link {
|
.jump-link {
|
||||||
padding: 0.5rem 0.8rem;
|
padding: 0.5rem 0.8rem;
|
||||||
}
|
}
|
||||||
.attack-bar {
|
|
||||||
top: 3.4rem;
|
|
||||||
max-height: 55dvh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
[data-jump] {
|
[data-jump] {
|
||||||
scroll-margin-top: 3.8rem;
|
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. */
|
/* Everyone in the scene on one line that stays under the top bar. */
|
||||||
.turn-strip {
|
.turn-strip {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 3.6rem;
|
top: var(--topbar-h);
|
||||||
z-index: 25;
|
z-index: 25;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
@@ -2813,20 +2824,17 @@ button.slot-pip:disabled {
|
|||||||
.turn-chip.has-acted {
|
.turn-chip.has-acted {
|
||||||
opacity: 0.55;
|
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. */
|
/* On a wide screen the side column stays in view, scrolling on its own. */
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
.encounter-side {
|
.encounter-side {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 7.6rem;
|
top: calc(var(--topbar-h) + 4rem);
|
||||||
max-height: calc(100vh - 8.4rem);
|
max-height: calc(100vh - var(--topbar-h) - 4.8rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
|
/* Room to scroll the last card up past the quick-stats button. */
|
||||||
|
padding-bottom: 5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.spotlight {
|
.spotlight {
|
||||||
@@ -2886,6 +2894,7 @@ button.slot-pip:disabled {
|
|||||||
summary.card-title {
|
summary.card-title {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
summary.card-title::-webkit-details-marker {
|
summary.card-title::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -2902,15 +2911,6 @@ details:not([open]) > summary.card-title::after {
|
|||||||
content: '▸';
|
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. */
|
/* In one column the card on turn is already at the top of the list. */
|
||||||
@media (max-width: 959px) {
|
@media (max-width: 959px) {
|
||||||
.spotlight,
|
.spotlight,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { GearInForce } from '../components/CharacterPanel';
|
|||||||
import { SheetNumbers } from '../components/SheetNumbers';
|
import { SheetNumbers } from '../components/SheetNumbers';
|
||||||
import { ConditionChip, ConditionPicker, ResourceTracker, StateBadges } from '../components/CharacterState';
|
import { ConditionChip, ConditionPicker, ResourceTracker, StateBadges } from '../components/CharacterState';
|
||||||
import { useHotkeys } from '../hooks/useHotkeys';
|
import { useHotkeys } from '../hooks/useHotkeys';
|
||||||
|
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||||
import { Rollable } from '../components/Rollable';
|
import { Rollable } from '../components/Rollable';
|
||||||
import { GroupCheckModal } from '../components/GroupCheckModal';
|
import { GroupCheckModal } from '../components/GroupCheckModal';
|
||||||
import { withBonus } from '../lib/diceRoller';
|
import { withBonus } from '../lib/diceRoller';
|
||||||
@@ -496,6 +497,7 @@ function CombatantCard({
|
|||||||
onTarget,
|
onTarget,
|
||||||
isPinned,
|
isPinned,
|
||||||
onPin,
|
onPin,
|
||||||
|
detailsOpen,
|
||||||
onRemove,
|
onRemove,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
@@ -519,17 +521,18 @@ function CombatantCard({
|
|||||||
// Whether this combatant's stats are the ones kept in view, and the switch for it.
|
// Whether this combatant's stats are the ones kept in view, and the switch for it.
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
onPin: () => void;
|
onPin: () => void;
|
||||||
|
// Whether the details start open (until the viewer opens or closes them).
|
||||||
|
detailsOpen: boolean;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onError: (err: unknown) => void;
|
onError: (err: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const [hpDelta, setHpDelta] = useState('');
|
const [hpDelta, setHpDelta] = useState('');
|
||||||
// How many rounds the next condition added lasts; empty for until removed.
|
// How many rounds the next condition added lasts; empty for until removed.
|
||||||
const [rounds, setRounds] = useState('');
|
const [rounds, setRounds] = useState('');
|
||||||
// Details start open for the DM (who runs the scene from this screen) and
|
// null = "not toggled yet", so the details follow detailsOpen (which
|
||||||
// closed for players; null = "not toggled yet", so it follows isDM once the
|
// changes as the turn passes, and once the campaign role has loaded).
|
||||||
// campaign role has loaded.
|
|
||||||
const [detailsToggled, setDetailsToggled] = useState<boolean | null>(null);
|
const [detailsToggled, setDetailsToggled] = useState<boolean | null>(null);
|
||||||
const showDetails = detailsToggled ?? isDM;
|
const showDetails = detailsToggled ?? detailsOpen;
|
||||||
|
|
||||||
const info = p.characterCombatInfo;
|
const info = p.characterCombatInfo;
|
||||||
const monster = p.monster;
|
const monster = p.monster;
|
||||||
@@ -987,12 +990,14 @@ function ExperiencePanel({
|
|||||||
difficulty,
|
difficulty,
|
||||||
xpAwarded,
|
xpAwarded,
|
||||||
partySize,
|
partySize,
|
||||||
|
folded,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
encounterId: string;
|
encounterId: string;
|
||||||
difficulty: EncounterDifficulty;
|
difficulty: EncounterDifficulty;
|
||||||
xpAwarded: number | null;
|
xpAwarded: number | null;
|
||||||
partySize: number;
|
partySize: number;
|
||||||
|
folded: boolean; // starts closed, as it does while a battle is under way
|
||||||
onError: (err: unknown) => void;
|
onError: (err: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const awardXp = useAwardXp(encounterId);
|
const awardXp = useAwardXp(encounterId);
|
||||||
@@ -1001,11 +1006,11 @@ function ExperiencePanel({
|
|||||||
const { thresholds, rating } = difficulty;
|
const { thresholds, rating } = difficulty;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<details className="card" open={!folded}>
|
||||||
<div className="card-title">
|
<summary className="card-title">
|
||||||
<span>Experience</span>
|
<span>Experience</span>
|
||||||
{rating && <span className={`badge ${RATING_BADGES[rating]}`}>{RATING_LABELS[rating]}</span>}
|
{rating && <span className={`badge ${RATING_BADGES[rating]}`}>{RATING_LABELS[rating]}</span>}
|
||||||
</div>
|
</summary>
|
||||||
{difficulty.monsters === 0 ? (
|
{difficulty.monsters === 0 ? (
|
||||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
<p className="combatant-sub" style={{ marginTop: 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.
|
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({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1076,6 +1081,7 @@ function RewardsPanel({
|
|||||||
characters,
|
characters,
|
||||||
weapons,
|
weapons,
|
||||||
items,
|
items,
|
||||||
|
folded,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
encounter: Encounter;
|
encounter: Encounter;
|
||||||
@@ -1083,6 +1089,7 @@ function RewardsPanel({
|
|||||||
characters: CharacterSummary[];
|
characters: CharacterSummary[];
|
||||||
weapons: Weapon[];
|
weapons: Weapon[];
|
||||||
items: Item[];
|
items: Item[];
|
||||||
|
folded: boolean; // starts closed, as it does while a battle is under way
|
||||||
onError: (err: unknown) => void;
|
onError: (err: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const addReward = useAddReward(encounter.id);
|
const addReward = useAddReward(encounter.id);
|
||||||
@@ -1130,8 +1137,8 @@ function RewardsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<details className="card" open={!folded}>
|
||||||
<div className="card-title">Rewards</div>
|
<summary className="card-title">Rewards{rewards.length > 0 && <span className="section-count">{rewards.length}</span>}</summary>
|
||||||
{isDM && rewards.length === 0 && (
|
{isDM && rewards.length === 0 && (
|
||||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||||
Line up coins, weapons and items here. Players only see a reward once you award it.
|
Line up coins, weapons and items here. Players only see a reward once you award it.
|
||||||
@@ -1284,7 +1291,7 @@ function RewardsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1319,6 +1326,8 @@ export function EncounterPage() {
|
|||||||
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
||||||
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
||||||
const navigate = useNavigate();
|
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 [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
|
||||||
const [pickId, setPickId] = useState('');
|
const [pickId, setPickId] = useState('');
|
||||||
@@ -1454,6 +1463,17 @@ export function EncounterPage() {
|
|||||||
);
|
);
|
||||||
const unrolled = participants.filter((p) => p.kind !== 'CHARACTER' && p.initiativeRoll == null).length;
|
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) => (
|
const card = (p: EncounterParticipant) => (
|
||||||
<CombatantCard
|
<CombatantCard
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -1473,6 +1493,7 @@ export function EncounterPage() {
|
|||||||
}
|
}
|
||||||
isPinned={p.id === pinnedId}
|
isPinned={p.id === pinnedId}
|
||||||
onPin={() => setPinnedId(p.id === pinnedId ? null : p.id)}
|
onPin={() => setPinnedId(p.id === pinnedId ? null : p.id)}
|
||||||
|
detailsOpen={detailsOpen(p)}
|
||||||
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
@@ -1655,7 +1676,7 @@ export function EncounterPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(isDM || encounter.description) && (
|
{(isDM || encounter.description) && showScene && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
|
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
|
||||||
{isDM ? (
|
{isDM ? (
|
||||||
@@ -1790,8 +1811,8 @@ export function EncounterPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isDM && (
|
{isDM && (
|
||||||
<div className="card">
|
<details className="card" open={!inBattle}>
|
||||||
<div className="card-title">Add to the scene</div>
|
<summary className="card-title">Add to the scene</summary>
|
||||||
<div className="flex-row flex-wrap gap-sm">
|
<div className="flex-row flex-wrap gap-sm">
|
||||||
<select
|
<select
|
||||||
aria-label="What to add"
|
aria-label="What to add"
|
||||||
@@ -1855,7 +1876,7 @@ export function EncounterPage() {
|
|||||||
Create {pickType === 'npc' ? 'NPCs' : 'monsters'} on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link> first.
|
Create {pickType === 'npc' ? 'NPCs' : 'monsters'} on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link> first.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isBattle && (encounter.recentActions ?? []).length > 0 && (
|
{isBattle && (encounter.recentActions ?? []).length > 0 && (
|
||||||
@@ -1895,6 +1916,7 @@ export function EncounterPage() {
|
|||||||
difficulty={encounter.difficulty}
|
difficulty={encounter.difficulty}
|
||||||
xpAwarded={encounter.xpAwarded ?? null}
|
xpAwarded={encounter.xpAwarded ?? null}
|
||||||
partySize={participants.filter((p) => p.kind === 'CHARACTER' && p.character).length}
|
partySize={participants.filter((p) => p.kind === 'CHARACTER' && p.character).length}
|
||||||
|
folded={inBattle}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1905,6 +1927,7 @@ export function EncounterPage() {
|
|||||||
characters={builtCharacters}
|
characters={builtCharacters}
|
||||||
weapons={weapons ?? []}
|
weapons={weapons ?? []}
|
||||||
items={items ?? []}
|
items={items ?? []}
|
||||||
|
folded={inBattle}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.31.0",
|
"version": "1.32.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.31.0",
|
"version": "1.32.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"backend",
|
"backend",
|
||||||
"frontend"
|
"frontend"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
"version": "1.31.0",
|
"version": "1.32.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"version": "1.31.0",
|
"version": "1.32.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.103.2",
|
"@tanstack/react-query": "^5.103.2",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.31.0",
|
"version": "1.32.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "D&D 5e campaign manager — monorepo root",
|
"description": "D&D 5e campaign manager — monorepo root",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
Reference in New Issue
Block a user