Sight groups, DM reveal areas (cones), client-side image shrink for map upload

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-24 18:33:08 +00:00
parent 6aac03f112
commit ab3c35aab7
8 changed files with 243 additions and 29 deletions
+59 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
import { MapView, MapLibrary, NpcPicker, RadialMenu, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
import { MapView, MapLibrary, NpcPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
@@ -565,6 +565,25 @@ function CharTab({ cid, rules, onRoll, tick, editable }) {
)
}
// DM: players in the same sight group see what anyone in the group sees
function VisionGroups({ lobby, id, act }) {
const set = (b) => act(`/api/lobbies/${id}/vision-groups`, 'PUT', b)
return (
<div className="card visiongroups">
<h4>Sight groups</h4>
<p className="small muted">Players in the same group share what they see (with line of sight on). “Alone” means only their own view.</p>
<div className="row"><button className="ghost" onClick={() => set({ preset: 'shared' })}>Everyone shares sight</button><button className="ghost" onClick={() => set({ preset: 'separate' })}>Everyone alone</button></div>
{lobby.members.map((m) => (
<label key={m.username} className="small row vgrow">{m.character?.name || m.username}
<select value={m.vgroup || 0} onChange={(e) => set({ groups: { [m.username]: Number(e.target.value) } })} aria-label={`Sight group of ${m.username}`}>
<option value={0}>Alone</option>{[1, 2, 3, 4].map((g) => <option key={g} value={g}>Group {'ABCD'[g - 1]}</option>)}
</select>
</label>
))}
</div>
)
}
// lobby settings: join code, rename, colour, character switch, members, leave / delete
// DM: replace the dice sound, add sounds for critical results, switch cues on or off
function SoundSettings({ lobby, id, act, sfx }) {
@@ -756,6 +775,14 @@ function LobbyView({ id, rules, user }) {
useEffect(() => { document.documentElement.style.setProperty('--chatw', `${chatW}px`); store('aw_chatw', String(chatW)) }, [chatW])
useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), [])
useEffect(() => { store('aw_sheetw', String(sheetW)) }, [sheetW])
const [rv, setRv] = useState(null) // DM "show an area" tool: {origin, target, hover, fov, to}
const rvShape = (() => {
if (!rv?.origin) return null
const t = rv.target || rv.hover
if (!t) return { x: rv.origin.x, y: rv.origin.y, radius: 0.01, dir: 0, fov: rv.fov }
const dx = t.x - rv.origin.x, dy = t.y - rv.origin.y
return { x: rv.origin.x, y: rv.origin.y, radius: Math.max(0.5, Math.hypot(dx, dy) / (mapState.map?.grid || 50)), dir: (Math.atan2(dy, dx) * 180) / Math.PI, fov: rv.fov }
})()
const [menu, setMenu] = useState(null) // right click menu on a token
const [topOpen, setTopOpen] = useState(() => store('aw_topopen') === '1') // difficulty and player bar during a fight
const toggleTop = () => { setTopOpen((v) => { store('aw_topopen', v ? '0' : '1'); return !v }) }
@@ -824,7 +851,7 @@ function LobbyView({ id, rules, user }) {
<span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span>
</div>
{lobby.is_dm && leftTab === 'lobby'
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><VisionGroups lobby={lobby} id={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
: npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} />
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} editable={cur.me} />
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
@@ -869,7 +896,11 @@ function LobbyView({ id, rules, user }) {
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
onMenu={lobby.is_dm && !viewAs ? (t, x, y) => setMenu({ t, x, y }) : undefined}
isTurn={(t) => { const e = lobby.combat?.entries.find((x) => x.id === lobby.combat.current); return !!e && (e.kind === 'npc' ? e.token === t.id : e.user === t.username) }}
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}>
tool={rv ? 'reveal' : undefined}
onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : undefined}
onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : undefined}
overlay={<>{walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}
{full && <RevealLayer map={mapState.map} zones={mapState.reveals} draft={rv && rvShape && rv.origin ? rvShape : null} />}</>}>
{mapState.vision?.empty && <div className="mapempty">{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}</div>}
<div className="maptools mapui">
{lobby.is_dm && (
@@ -884,12 +915,37 @@ function LobbyView({ id, rules, user }) {
<button className={mapState.fog_on ? 'on' : 'ghost'} aria-pressed={!!mapState.fog_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { fog: !mapState.fog_on }, () => loadMap())} title="Fog of war: players keep a dim memory of what they have seen">Fog</button>
{mapState.fog_on && <button className="ghost" onClick={() => window.confirm('Forget everything the players have explored?') && act(`/api/lobbies/${id}/explored`, 'DELETE', undefined, () => loadMap())} title="Forget the explored ground">↺</button>}
</>}
{full && mapState.walls?.length > 0 && mapState.los_on && <button className={rv ? 'on' : 'ghost'} aria-pressed={!!rv} onClick={() => setRv(rv ? null : { origin: null, target: null, hover: null, fov: 90, to: null })} title="Show players a far away area: click the spot, then click where it ends">👁 Reveal</button>}
{lobby.is_dm && mapState.walls?.length > 0 && <button className={mapState.los_on ? 'on' : 'ghost'} aria-pressed={!!mapState.los_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Line of sight: players only see what their character can see">Sight {mapState.los_on ? 'on' : 'off'}</button>}
{full && mapState.walls?.length > 0 && <button className={showWalls ? 'on' : 'ghost'} aria-pressed={showWalls} onClick={() => setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls</button>}
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
{full && <button className="ghost" onClick={addToken}>+ Token</button>}
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>}
</div>
{full && (rv || mapState.reveals?.length > 0) && (
<div className="tokenpanel mapui revealpanel">
<b className="small">Reveal an area</b>
{rv && <>
<p className="small muted">{!rv.origin ? '1. Click the spot the view starts from.' : !rv.target ? '2. Move and click where the view ends (distance = range, direction = where it looks).' : 'Ready. Click again to change the target.'}</p>
<label className="small">Cone width {rv.fov >= 359 ? '360° (all around)' : `${rv.fov}°`}
<input type="range" min="20" max="360" step="10" value={rv.fov} onChange={(e) => setRv({ ...rv, fov: Number(e.target.value) })} /></label>
<div className="small">Shown to:
<label className="chk"><input type="checkbox" checked={!rv.to} onChange={() => setRv({ ...rv, to: null })} /> everyone</label>
{lobby.members.map((mm) => <label key={mm.username} className="chk"><input type="checkbox" checked={!!rv.to?.includes(mm.username)}
onChange={(e) => setRv({ ...rv, to: e.target.checked ? [...(rv.to || []), mm.username] : (rv.to || []).filter((n) => n !== mm.username) })} /> {mm.character?.name || mm.username}</label>)}
</div>
<div className="row">
<button className="on" disabled={!rv.target || (rv.to && !rv.to.length)} onClick={() => act(`/api/lobbies/${id}/reveals`, 'POST', { x: rvShape.x, y: rvShape.y, radius: rvShape.radius, dir: rvShape.dir, fov: rv.fov, to: rv.to }, () => { setRv(null); loadMap() })}>Reveal</button>
<button className="ghost" onClick={() => setRv(null)}>Cancel</button>
</div>
</>}
{mapState.reveals?.length > 0 && <>
<b className="small">Shown now</b>
{mapState.reveals.map((z, i) => <div key={z.id} className="row small">Area {i + 1} · {z.to ? z.to.join(', ') : 'everyone'}
<button className="ghost mini" onClick={() => act(`/api/lobbies/${id}/reveals/${z.id}`, 'DELETE', undefined, () => loadMap())}>Hide</button></div>)}
</>}
</div>
)}
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
{full && mapSel && (() => { const st = mapState.tokens.find((t) => t.id === mapSel); return st && !selTok ? <div className="tokenpanel mapui"><b className="small">{st.name || 'Player'}</b><StatusForm t={st} conditions={rules.conditions || []} onChange={(p) => patchToken(st.id, p)} /></div> : null })()}
{selTok && <div className="tokenpanel mapui"><TokenForm t={selTok} onChange={(p) => patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} /><StatusForm t={selTok} conditions={rules.conditions || []} onChange={(p) => patchToken(selTok.id, p)} /></div>}
+18
View File
@@ -254,6 +254,24 @@ export function NpcPicker({ onPick, onClose }) {
const uid = () => Math.random().toString(36).slice(2, 12)
// Areas the DM shows to players: a circle or cone. zones = revealed ones, draft = being placed. Units: pixels / degrees.
export function RevealLayer({ map, zones, draft }) {
const shape = (z, key, cls) => {
const R = z.radius * map.grid
if (z.fov >= 359) return <circle key={key} className={cls} cx={z.x} cy={z.y} r={R} vectorEffect="non-scaling-stroke" />
const a0 = ((z.dir - z.fov / 2) * Math.PI) / 180, a1 = ((z.dir + z.fov / 2) * Math.PI) / 180
const d = `M ${z.x} ${z.y} L ${z.x + R * Math.cos(a0)} ${z.y + R * Math.sin(a0)} A ${R} ${R} 0 ${z.fov > 180 ? 1 : 0} 1 ${z.x + R * Math.cos(a1)} ${z.y + R * Math.sin(a1)} Z`
return <path key={key} className={cls} d={d} vectorEffect="non-scaling-stroke" />
}
return (
<svg className="revealsvg" width={map.w} height={map.h} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
{(zones || []).map((z) => shape(z, z.id, 'rz'))}
{draft && shape(draft, 'draft', 'rz draft')}
{draft && <circle cx={draft.x} cy={draft.y} r="6" className="rzo" vectorEffect="non-scaling-stroke" />}
</svg>
)
}
// Walls and doors as a line layer on top of the map (DM only). In the live view doors can be clicked to open or close them.
export function WallLayer({ map, walls, draft, hover, onDoor }) {
const line = (w, extra = {}) => (
+20 -2
View File
@@ -2,7 +2,24 @@ import { useCallback, useEffect, useState } from 'react'
import { MapEditor } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
const errText = async (r, fallback) => (await r.json().catch(() => ({}))).detail || fallback
const errText = async (r, fallback) => {
if (r.status === 413) return 'The file is too big for the web server (nginx client_max_body_size). Use a smaller image or ask the server admin to raise the limit.'
return (await r.json().catch(() => ({}))).detail || fallback
}
// Big images are scaled down and re-encoded in the browser first (the server does it again anyway), so they fit through proxies with small upload limits.
async function shrink(f) {
if (f.size < 900_000) return f
try {
const bmp = await createImageBitmap(f)
const k = Math.min(1, 4096 / Math.max(bmp.width, bmp.height))
const c = document.createElement('canvas')
c.width = Math.max(1, Math.round(bmp.width * k)); c.height = Math.max(1, Math.round(bmp.height * k))
c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height)
const blob = await new Promise((res) => c.toBlob(res, 'image/webp', 0.9))
return blob && blob.size < f.size ? blob : f
} catch { return f }
}
function SharePanel({ m, reload }) {
const [name, setName] = useState('')
@@ -45,7 +62,8 @@ export default function Maps() {
e.target.value = ''
if (!f) return
setErr(''); setBusy(true)
const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f }).catch(() => null)
const body = await shrink(f)
const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': body.type || 'application/octet-stream' }, body }).catch(() => null)
setBusy(false)
if (!r || !r.ok) return setErr(r ? await errText(r, 'Upload failed') : 'Upload failed')
load()
+8
View File
@@ -587,3 +587,11 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
.condrow { display: flex; flex-wrap: wrap; gap: .25rem; }
.sightrange input { width: 3.4rem; }
.tokenpanel { max-height: calc(100% - 4rem); overflow-y: auto; }
/* reveal areas, sight groups */
.rz { fill: rgba(255, 215, 0, .14); stroke: rgba(255, 215, 0, .9); stroke-width: 2; stroke-dasharray: 6 4; }
.rz.draft { fill: rgba(255, 215, 0, .25); }
.rzo { fill: gold; stroke: #000; stroke-width: 1; }
.revealpanel { right: auto; left: .5rem; bottom: .5rem; top: auto; max-width: 18rem; display: grid; gap: .4rem; }
.visiongroups { display: grid; gap: .4rem; }
.vgrow { justify-content: space-between; }