P11: hide fogged tokens from players + show combat conditions
- toPlayerProjection now drops tokens whose footprint is unrevealed when fog is on, so a token's name/position stays hidden in the dark (tested). - Player initiative view shows each combatant's visible conditions (playerCombatant + wire schema + PlayerBoards chips) — monster conditions are now visible to players. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,11 +73,16 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
{encounter.combatants.map((c) => (
|
{encounter.combatants.map((c) => (
|
||||||
<li key={c.id} className={cn('flex items-center gap-3 rounded-md border px-3 py-2', c.isCurrent ? 'border-accent bg-elevated ring-1 ring-accent/40' : 'border-line bg-panel')}>
|
<li key={c.id} className={cn('flex items-center gap-3 rounded-md border px-3 py-2', c.isCurrent ? 'border-accent bg-elevated ring-1 ring-accent/40' : 'border-line bg-panel')}>
|
||||||
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span>
|
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span>
|
||||||
<span className="flex-1 text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent">◀ turn</span>}</span>
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
|
||||||
|
<span className="text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent">◀ turn</span>}</span>
|
||||||
|
{c.conditions.map((cond, i) => (
|
||||||
|
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
{c.hp ? (
|
{c.hp ? (
|
||||||
<span className="text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
|
<span className="shrink-0 text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
|
||||||
) : c.status ? (
|
) : c.status ? (
|
||||||
<span className={cn('text-sm font-medium', c.status.cls)}>{c.status.label}</span>
|
<span className={cn('shrink-0 text-sm font-medium', c.status.cls)}>{c.status.label}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface PlayerCombatant {
|
|||||||
/** exact HP for PCs; undefined for hidden enemies */
|
/** exact HP for PCs; undefined for hidden enemies */
|
||||||
hp?: { current: number; max: number; temp: number };
|
hp?: { current: number; max: number; temp: number };
|
||||||
status?: EnemyStatus;
|
status?: EnemyStatus;
|
||||||
|
/** visible conditions (shown to players for everyone in the fight) */
|
||||||
|
conditions: { name: string; value?: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerEncounter {
|
export interface PlayerEncounter {
|
||||||
@@ -45,6 +47,7 @@ export function toPlayerEncounter(e: Encounter): PlayerEncounter {
|
|||||||
kind: c.kind,
|
kind: c.kind,
|
||||||
initiative: c.initiative,
|
initiative: c.initiative,
|
||||||
isCurrent: idx === e.turnIndex,
|
isCurrent: idx === e.turnIndex,
|
||||||
|
conditions: c.conditions.map((x) => ({ name: x.name, ...(x.value !== undefined ? { value: x.value } : {}) })),
|
||||||
...(isPC ? { hp: c.hp } : { status: enemyStatus(c) }),
|
...(isPC ? { hp: c.hp } : { status: enemyStatus(c) }),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { battleMapSchema } from '@/lib/schemas';
|
||||||
|
import { toPlayerProjection } from './projection';
|
||||||
|
|
||||||
|
function map(over: Record<string, unknown>) {
|
||||||
|
return battleMapSchema.parse({ id: 'm', campaignId: 'c', name: 'M', createdAt: 't', updatedAt: 't', gridSize: 50, ...over });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toPlayerProjection fog visibility', () => {
|
||||||
|
const tokens = [
|
||||||
|
{ id: 'lit', label: 'Hero', col: 0, row: 0, size: 1 },
|
||||||
|
{ id: 'dark', label: 'Lurker', col: 5, row: 5, size: 1 },
|
||||||
|
{ id: 'secret', label: 'Trap', col: 0, row: 0, size: 1, gmOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('hides tokens whose cells are unrevealed when fog is on', () => {
|
||||||
|
const p = toPlayerProjection(map({ fogEnabled: true, revealed: ['0,0'], tokens }));
|
||||||
|
expect(p.tokens.map((t) => t.id)).toEqual(['lit']); // dark = fogged, secret = gmOnly
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all non-gmOnly tokens when fog is off', () => {
|
||||||
|
const p = toPlayerProjection(map({ fogEnabled: false, revealed: [], tokens }));
|
||||||
|
expect(p.tokens.map((t) => t.id).sort()).toEqual(['dark', 'lit']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BattleMap } from '@/lib/schemas';
|
import type { BattleMap } from '@/lib/schemas';
|
||||||
|
import { tokenCells } from './grid';
|
||||||
import type { PlayerDrawing, PlayerMap, PlayerToken } from './types';
|
import type { PlayerDrawing, PlayerMap, PlayerToken } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -7,8 +8,14 @@ import type { PlayerDrawing, PlayerMap, PlayerToken } from './types';
|
|||||||
* Dexie). Revealed cells pass through (the player renderer fogs the rest).
|
* Dexie). Revealed cells pass through (the player renderer fogs the rest).
|
||||||
*/
|
*/
|
||||||
export function toPlayerProjection(map: BattleMap): PlayerMap {
|
export function toPlayerProjection(map: BattleMap): PlayerMap {
|
||||||
|
// When fog is on, a token is only visible to players if at least one of its
|
||||||
|
// footprint cells is revealed — so names/positions stay hidden in the dark.
|
||||||
|
const revealed = new Set(map.revealed);
|
||||||
|
const visible = (t: BattleMap['tokens'][number]) =>
|
||||||
|
!map.fogEnabled || tokenCells({ col: t.col, row: t.row, size: t.size }).some((k) => revealed.has(k));
|
||||||
|
|
||||||
const tokens: PlayerToken[] = map.tokens
|
const tokens: PlayerToken[] = map.tokens
|
||||||
.filter((t) => !t.gmOnly)
|
.filter((t) => !t.gmOnly && visible(t))
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
id: t.id, label: t.label, color: t.color, col: t.col, row: t.row, size: t.size, kind: t.kind,
|
id: t.id, label: t.label, color: t.color, col: t.col, row: t.row, size: t.size, kind: t.kind,
|
||||||
...(t.hp ? { hp: t.hp } : {}),
|
...(t.hp ? { hp: t.hp } : {}),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe('sync protocol', () => {
|
|||||||
campaignName: 'X', calendarDay: null,
|
campaignName: 'X', calendarDay: null,
|
||||||
party: [{ id: 'a', name: 'Hero', level: 3, ac: 15, hp: { current: 10, max: 20, temp: 0 }, conditions: [] }],
|
party: [{ id: 'a', name: 'Hero', level: 3, ac: 15, hp: { current: 10, max: 20, temp: 0 }, conditions: [] }],
|
||||||
encounter: { id: 'e', name: 'Fight', round: 1, combatants: [
|
encounter: { id: 'e', name: 'Fight', round: 1, combatants: [
|
||||||
{ id: 'm', name: 'Goblin', kind: 'monster', initiative: 12, isCurrent: false, status: { label: 'Bloodied', cls: 'text-warning' } },
|
{ id: 'm', name: 'Goblin', kind: 'monster', initiative: 12, isCurrent: false, status: { label: 'Bloodied', cls: 'text-warning' }, conditions: [{ name: 'frightened', value: 1 }] },
|
||||||
] },
|
] },
|
||||||
map: null, mapImageId: null,
|
map: null, mapImageId: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const playerCombatantSchema = z.object({
|
|||||||
isCurrent: z.boolean(),
|
isCurrent: z.boolean(),
|
||||||
hp: hpSchema.optional(),
|
hp: hpSchema.optional(),
|
||||||
status: z.object({ label: z.string(), cls: z.string() }).optional(),
|
status: z.object({ label: z.string(), cls: z.string() }).optional(),
|
||||||
|
conditions: z.array(condSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const playerEncounterSchema = z.object({
|
export const playerEncounterSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user