Token HP and conditions with DM visibility toggles, fog of war, live sight range

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:21:39 +00:00
parent a2103566a0
commit 6aac03f112
7 changed files with 248 additions and 21 deletions
+26 -4
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, TokenForm, WallLayer } from './MapView.jsx'
import { MapView, MapLibrary, NpcPicker, RadialMenu, 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) })
@@ -168,6 +168,17 @@ function useMapImage(id, vision, as) {
return img
}
// The ground this player has already seen (dimmed under the live view). Reloaded a moment after the picture changes.
function useFogImage(id, rev, as) {
const [src, setSrc] = useState(null)
useEffect(() => {
if (!rev) { setSrc(null); return }
const t = setTimeout(() => setSrc(`/api/lobbies/${id}/map/explored?v=${rev}${as ? `&as=${encodeURIComponent(as)}` : ''}`), 400)
return () => clearTimeout(t)
}, [id, rev, as])
return src
}
// Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open.
function useFeed(id, onEvent, onRoll, socketUp) {
const [msgs, setMsgs] = useState([])
@@ -689,6 +700,7 @@ function LobbyView({ id, rules, user }) {
const [showWalls, setShowWalls] = useState(true)
const loadMap = useCallback(() => fetch(`/api/lobbies/${id}/map${viewAs ? `?as=${encodeURIComponent(viewAs)}` : ''}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setMapState(d)).catch(() => {}), [id, viewAs])
const mapImg = useMapImage(id, mapState.vision, viewAs)
const fogImg = useFogImage(id, mapState.vision?.fog, viewAs)
const sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const up = useSocket(id, {
@@ -750,9 +762,10 @@ function LobbyView({ id, rules, user }) {
const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b)
const [npcPick, setNpcPick] = useState(false)
const addNpcToken = (n) => {
const hp = n.hp > 0 ? n.hp : undefined
const m = mapState.map
setNpcPick(false)
send(`/api/lobbies/${id}/tokens`, 'POST', { name: n.name, npc_id: n.id, x: m.w / 2, y: m.h / 2, size: n.size || 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
send(`/api/lobbies/${id}/tokens`, 'POST', { name: n.name, npc_id: n.id, x: m.w / 2, y: m.h / 2, size: n.size || 1, color: '#c0392b', hp_max: hp }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
}
const addToken = () => {
const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1
@@ -852,7 +865,7 @@ function LobbyView({ id, rules, user }) {
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
const walls = full && showWalls ? mapState.walls || [] : []
return (
<MapView map={mapState.map} image={mapImg} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
<MapView map={mapState.map} image={mapImg} fog={fogImg} portraitUrl={(t) => t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid}
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) }}
@@ -865,6 +878,12 @@ function LobbyView({ id, rules, user }) {
{lobby.members.map((m) => <option key={m.username} value={m.username}>View as {m.character?.name || m.username}</option>)}
</select>
)}
{lobby.is_dm && mapState.walls?.length > 0 && mapState.los_on && <>
<label className="small sightrange" title="How far players see, in squares (0 = unlimited). Resets to the map's value when you switch maps."><input type="number" min="0" max="1000" key={mapState.sight} defaultValue={mapState.sight} aria-label="Sight range in squares"
onBlur={(e) => e.target.value !== String(mapState.sight) && act(`/api/lobbies/${id}/sight`, 'PUT', { range: e.target.value }, () => loadMap())} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} /> sq.</label>
<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>}
</>}
{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>
@@ -872,7 +891,8 @@ function LobbyView({ id, rules, user }) {
{full && <button className={npcPick ? 'on' : 'ghost'} onClick={() => setNpcPick(!npcPick)}>+ NPC</button>}
</div>
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
{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) }} /></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>}
</MapView>
)
})()}
@@ -882,6 +902,8 @@ function LobbyView({ id, rules, user }) {
return <RadialMenu x={menu.x} y={menu.y} title={t.name} onClose={() => setMenu(null)} items={[
{ icon: '⚔', label: e ? 'In fight' : fight ? 'Add to fight' : 'No fight', disabled: !fight || !!e, onClick: () => combatOp(t.kind === 'npc' ? { op: 'add', token: t.id } : { op: 'add', user: t.username }) },
{ icon: '📄', label: 'Sheet', disabled: t.kind === 'npc' && !t.npc_id, onClick: () => { if (t.kind === 'npc') setMapSel(t.id); else { setSel(t.username); setMapSel(null) } setLeftTab('sheet'); setSheetOpen(true) } },
{ icon: '❤', label: t.show_hp ? 'HP shown' : 'HP hidden', disabled: !(t.hp_max > 0), onClick: () => patchToken(t.id, { show_hp: !t.show_hp }) },
{ icon: '☣', label: t.show_cond ? 'Cond. shown' : 'Cond. hidden', onClick: () => patchToken(t.id, { show_cond: !t.show_cond }) },
...(t.kind === 'npc' ? [
{ icon: t.hidden ? '👁' : '🚫', label: t.hidden ? 'Reveal' : 'Hide', onClick: () => patchToken(t.id, { hidden: !t.hidden }) },
{ icon: '🗑', label: 'Remove', onClick: () => { send(`/api/lobbies/${id}/tokens/${t.id}`, 'DELETE'); setMapSel(null) } }] : []),