NPC editor, NPC tokens from library, admin management
- NPC library (own + shared) built on the character wizard with a free NPC preset - NPC tokens in map editor and lobby, DM sheet on token click, DM checks for NPCs - Admins can promote/demote other users Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -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, TokenForm, WallLayer } from './MapView.jsx'
|
||||
import { MapView, MapLibrary, NpcPicker, 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) })
|
||||
@@ -680,6 +680,12 @@ function LobbyView({ id, rules, user }) {
|
||||
setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) }))
|
||||
return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null)
|
||||
}
|
||||
const [npcPick, setNpcPick] = useState(false)
|
||||
const addNpcToken = (n) => {
|
||||
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(() => {})
|
||||
}
|
||||
const addToken = () => {
|
||||
const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1
|
||||
send(`/api/lobbies/${id}/tokens`, 'POST', { name: `Enemy ${n}`, x: m.w / 2, y: m.h / 2, size: 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
|
||||
@@ -692,6 +698,7 @@ function LobbyView({ id, rules, user }) {
|
||||
const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
|
||||
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
|
||||
const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
|
||||
const npcTok = lobby.is_dm && !viewAs ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc' && t.npc_id) : null
|
||||
const dest = people.includes(to) ? to : ''
|
||||
const canCheck = !!(me && me.character_id)
|
||||
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
|
||||
@@ -708,13 +715,13 @@ function LobbyView({ id, rules, user }) {
|
||||
if (own) return setPending({ type: 'dice', sides, count })
|
||||
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
|
||||
}
|
||||
const check = (kind, cid, name) => {
|
||||
const check = (kind, cid, name, npcToken) => {
|
||||
if (busy) return
|
||||
if (own) return setPending({ type: 'check', kind, id: cid, name })
|
||||
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '' })
|
||||
if (own) return setPending({ type: 'check', kind, id: cid, name, npcToken })
|
||||
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '', ...(npcToken ? { npc_token: npcToken } : {}) })
|
||||
}
|
||||
const submitManual = (vals) => {
|
||||
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '' }, () => setPending(null))
|
||||
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '', ...(pending.npcToken ? { npc_token: pending.npcToken } : {}) }, () => setPending(null))
|
||||
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
|
||||
}
|
||||
|
||||
@@ -737,7 +744,7 @@ function LobbyView({ id, rules, user }) {
|
||||
<div className="sheetbar">
|
||||
{lobby.is_dm ? (
|
||||
<span className="seg" role="tablist">
|
||||
<button role="tab" className={leftTab === 'sheet' ? 'on' : 'ghost'} aria-selected={leftTab === 'sheet'} onClick={() => setLeftTab('sheet')}>Sheet{cur ? `: ${cur.character.name}` : ''}</button>
|
||||
<button role="tab" className={leftTab === 'sheet' ? 'on' : 'ghost'} aria-selected={leftTab === 'sheet'} onClick={() => setLeftTab('sheet')}>Sheet{npcTok ? `: ${npcTok.name}` : cur ? `: ${cur.character.name}` : ''}</button>
|
||||
<button role="tab" className={leftTab === 'lobby' ? 'on' : 'ghost'} aria-selected={leftTab === 'lobby'} onClick={() => setLeftTab('lobby')}>Lobby actions</button>
|
||||
</span>
|
||||
) : <b>{cur ? cur.character.name : 'Character sheet'}</b>}
|
||||
@@ -745,6 +752,7 @@ function LobbyView({ id, rules, user }) {
|
||||
</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} /></>
|
||||
: 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} />
|
||||
: <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>}
|
||||
</div>
|
||||
@@ -752,7 +760,7 @@ function LobbyView({ id, rules, user }) {
|
||||
|
||||
<div className="playarea">
|
||||
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
|
||||
<CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setLeftTab('sheet') }} />
|
||||
<CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setMapSel(null); setLeftTab('sheet') }} />
|
||||
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
|
||||
<div className="table">
|
||||
{showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
|
||||
@@ -761,7 +769,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 ? `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}` : null} tokens={mapState.tokens} grid={showGrid}
|
||||
<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}
|
||||
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
|
||||
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : 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>}
|
||||
@@ -775,8 +783,10 @@ function LobbyView({ id, rules, user }) {
|
||||
{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}>+ NPC</button>}
|
||||
{full && <button className="ghost" onClick={addToken}>+ Token</button>}
|
||||
{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>}
|
||||
</MapView>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user