From fdaf2a251bab28b7b77e0c52dcce4e3bb5af7ca0 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 30 Sep 2026 10:38:08 -0500 Subject: [PATCH] feat: the heart monitor's beat follows how hurt someone is, not just its color The monitor's color always changed with health; now its rhythm does too, on the same thresholds: steady over half, twice as fast at half or less, fast, uneven and weakening at a quarter or less, then the flatline. Still no numbers, in the HEALTH folder and on the stream overlay alike, which now share one band rather than two copies of the thresholds. With reduced motion the trace holds still but keeps its shape, and a screen reader hears how it looks. The steady beat is the trace the monitor has always drawn. --- CHANGELOG.md | 6 + README.md | 2 + frontend/src/components/HitPoints.tsx | 33 ++--- frontend/src/components/StreamerOverlay.tsx | 6 +- .../components/__tests__/healthBands.test.tsx | 122 ++++++++++++++++++ frontend/src/components/healthBands.ts | 55 ++++++++ 6 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/__tests__/healthBands.test.tsx create mode 100644 frontend/src/components/healthBands.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be98d3cc..037decb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). with a search box and arrow keys. Choosing a different system now asks first, since it changes the game for everyone online. +- **The heart monitor's beat follows how hurt someone is.** Its color always changed as a + character was hurt; now its rhythm does too. It stays steady over half health, beats twice as + fast at half or less, and turns fast, uneven and weak at a quarter or less, before the + flatline. It still shows no numbers, in the HEALTH folder and on the stream overlay alike. With + reduced motion turned on in the system settings, the trace holds still but keeps its shape. + ### Security - **Players can no longer use the GM's tools.** A player's own login worked as a key to the GM's diff --git a/README.md b/README.md index d1213fcc..c7a028eb 100644 --- a/README.md +++ b/README.md @@ -558,6 +558,7 @@ CITY_NET/ │ │ │ ├── PharmaSection.tsx # What is currently in the bloodstream, drawn in the sheet HEADER rather than a tab: a drug that wears off at the end of a scene and bills System Strain for it is not something to hide behind a tab somebody might not open. Draws nothing at all while a character is on nothing │ │ │ ├── XpWindow.tsx # AWARD_EXPERIENCE — points each rather than a pot to divide, with LEVEL_UP and LEVEL_DOWN for correcting a level on purpose │ │ │ ├── HitPoints.tsx # The HEALTH folder's two bodies: HitPointsPanel changes health (own token, or any for the GM) under a live heart monitor, with injuries and STIM_HEAL (CWN); HealthReviewPanel only watches someone else's - the monitor, a stun bar and the injury map, never a number - with STABILIZE for an ally on a mortal wound +│ │ │ ├── healthBands.ts # How hurt, as a band the heart monitor draws: its color and its rhythm (steady over half, twice as fast at half or less, fast, uneven and weakening at a quarter or less, flatline when down), on the same thresholds, for every system and the stream overlay │ │ │ ├── BankWindows.tsx # Player bank UI; the candle chart is nudged by balance changes on a log scale, so a fortune is a tall candle rather than a spike that flattens the rest │ │ │ ├── ChatWindow.tsx # In-game chat │ │ │ ├── DiceTray.tsx # Dice roller; SR6 pool results show a pulsing GLITCH / CRITICAL GLITCH banner; initiative rolls appear with full breakdown; `sidesForKey` picks the 3D shape (custom dice key results by name and carry their side count in `diceSides`) @@ -631,6 +632,7 @@ CITY_NET/ │ │ │ ├── DraggableWindow.test.tsx │ │ │ ├── BootScreen.test.tsx # Types out by itself and finishes, skips on SKIP and nothing else, reports lines and clicks │ │ │ ├── HitPoints.test.tsx +│ │ │ ├── healthBands.test.tsx # The bands' thresholds and colors, each rhythm's beats (the steady one unchanged, beats inside their stretch, weak ones drawn smaller), reduced motion, and the same band in the editing panel, the review panel and the stream │ │ │ ├── MapElements.test.tsx │ │ │ ├── MeasurementTool.test.tsx │ │ │ ├── SignRotation.test.tsx # LAY_FLAT / STAND_UP presets, per-axis sliders, all three axes reaching the PATCH body diff --git a/frontend/src/components/HitPoints.tsx b/frontend/src/components/HitPoints.tsx index 214f290c..04cec150 100644 --- a/frontend/src/components/HitPoints.tsx +++ b/frontend/src/components/HitPoints.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import type { Location } from '../types'; +import { bandOf, BAND_COLOR, BAND_WORDS, RHYTHM, beatPoints, type HealthBand } from './healthBands'; // Inline SVGs so we can tint them with CSS `color` (currentColor) export const PersonSVG = ({ color = 'currentColor', style }: { color?: string; style?: React.CSSProperties }) => ( @@ -200,7 +201,7 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on return (
- +
@@ -273,25 +274,33 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on // ─── Looking at someone else's health ─────────────────────────────────────── -export function HeartMonitor({ color, flatline }: { color: string; flatline: boolean }) { +/** + * The heart monitor: how badly hurt, as a picture. Its color and its rhythm both come from + * the band (healthBands.ts), so the beat quickens and falters as the color turns. With reduced + * motion the trace holds still, keeping the shape of its rhythm. + */ +export function HeartMonitor({ band }: { band: HealthBand }) { + const color = BAND_COLOR[band]; return ( -
+
- {flatline ? ( + {band === 'down' ? ( ) : ( - + - )} @@ -299,13 +308,6 @@ export function HeartMonitor({ color, flatline }: { color: string; flatline: boo ); } -/** The heart monitor's color: how badly hurt, without saying by how much. */ -export const hpColorFor = (hpCurrent: number, hpMax: number) => { - const pct = hpMax > 0 ? Math.max(0, Math.min(1, hpCurrent / hpMax)) : 0; - if (hpCurrent <= 0) return 'var(--danger)'; - return pct > 0.5 ? 'var(--green)' : pct > 0.25 ? 'var(--warning)' : 'var(--danger)'; -}; - interface HealthReviewPanelProps { location: Location & { injuries?: string }; /** CWN: any player viewing a downed character can attempt to stabilize @@ -356,7 +358,6 @@ export function HealthReviewPanel({ location, socket, gameSystem, onRolled }: He const injuries = parseInjuries(location.injuries); const hpCurrent = location.hp_current ?? 0; const isDead = hpCurrent <= 0; - const hpColor = hpColorFor(hpCurrent, location.hp_max ?? 0); return (
@@ -365,7 +366,7 @@ export function HealthReviewPanel({ location, socket, gameSystem, onRolled }: He
{/* Heart monitor — flatlines at 0 HP */} - + {/* SR6 Stun track (Physical is the token HP above) */} {stun && (() => { diff --git a/frontend/src/components/StreamerOverlay.tsx b/frontend/src/components/StreamerOverlay.tsx index f51f9f31..f338b61b 100644 --- a/frontend/src/components/StreamerOverlay.tsx +++ b/frontend/src/components/StreamerOverlay.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import type { DirectorState, Location } from '../types'; import { HeartMonitor, PersonSVG, INJURY_ZONES } from './HitPoints'; +import { bandOf } from './healthBands'; interface DiceEvent { id: string; @@ -117,9 +118,6 @@ export function StreamerOverlay({ socket, directorState, selectedLocation, battl {isRhombus && (() => { const hpCurrent = selectedLocation.hp_current ?? 0; const hpMax = selectedLocation.hp_max ?? 0; - const isDead = hpCurrent <= 0; - const hpPct = hpMax > 0 ? Math.max(0, Math.min(1, hpCurrent / hpMax)) : 0; - const hpColor = isDead ? 'var(--danger)' : hpPct > 0.5 ? 'var(--green)' : hpPct > 0.25 ? 'var(--warning)' : 'var(--danger)'; const injuries: Record = (() => { try { return JSON.parse((selectedLocation as any).injuries || '{}'); } catch { return {}; } })(); @@ -127,7 +125,7 @@ export function StreamerOverlay({ socket, directorState, selectedLocation, battl return ( <>
- +
{hasInjuries && (
diff --git a/frontend/src/components/__tests__/healthBands.test.tsx b/frontend/src/components/__tests__/healthBands.test.tsx new file mode 100644 index 00000000..78b334be --- /dev/null +++ b/frontend/src/components/__tests__/healthBands.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { bandOf, BAND_COLOR, RHYTHM, beatPoints } from '../healthBands'; +import { HeartMonitor, HealthReviewPanel, HitPointsPanel } from '../HitPoints'; +import { StreamerOverlay } from '../StreamerOverlay'; + +/** + * The heart monitor says how hurt someone is without a number: its color and its rhythm come + * from one band, on the same thresholds, so the beat quickens and falters as the color turns. + */ + +afterEach(cleanup); + +describe('the band', () => { + it('is steady over half, fast at half or less, critical at a quarter or less, and down at 0', () => { + expect(bandOf(20, 20)).toBe('steady'); + expect(bandOf(11, 20)).toBe('steady'); + expect(bandOf(10, 20)).toBe('fast'); + expect(bandOf(6, 20)).toBe('fast'); + expect(bandOf(5, 20)).toBe('critical'); + expect(bandOf(1, 20)).toBe('critical'); + expect(bandOf(0, 20)).toBe('down'); + expect(bandOf(-3, 20)).toBe('down'); + }); + + it('reads no maximum as badly hurt, as the color always has, and more than the maximum as whole', () => { + expect(bandOf(5, 0)).toBe('critical'); + expect(bandOf(30, 20)).toBe('steady'); + }); + + it('keeps the colors the monitor has always used', () => { + expect(BAND_COLOR).toEqual({ steady: 'var(--green)', fast: 'var(--warning)', critical: 'var(--danger)', down: 'var(--danger)' }); + }); +}); + +describe('the rhythm', () => { + it('over half is the one even beat the monitor has always drawn', () => { + expect(beatPoints(0, RHYTHM.steady.beats)).toBe('0,25 27,25 34,20 42,25 49,25 56,5 60,45 64,5 68,25 80,30 87,25 280,25'); + expect(RHYTHM.steady.seconds).toBe(2.4); + }); + + it('beats more often as the band worsens, and weakens when critical', () => { + expect(RHYTHM.fast.beats.length).toBe(2 * RHYTHM.steady.beats.length); + expect(RHYTHM.critical.beats.length).toBeGreaterThan(RHYTHM.fast.beats.length); + expect(RHYTHM.critical.seconds).toBeLessThan(RHYTHM.fast.seconds); + // Uneven: not every gap the same, and some beats barely there. + const gaps = RHYTHM.critical.beats.slice(1).map(([x], i) => x - RHYTHM.critical.beats[i][0]); + expect(new Set(gaps).size).toBeGreaterThan(1); + expect(Math.min(...RHYTHM.critical.beats.map(([, s]) => s))).toBeLessThan(0.5); + }); + + it('draws a weak beat smaller, around the same baseline', () => { + expect(beatPoints(0, [[0, 0.5]])).toBe('0,25 0,25 7,22.5 15,25 22,25 29,15 33,35 37,15 41,25 53,27.5 60,25 280,25'); + }); + + it('keeps every beat inside its stretch, so the trace repeats without a seam', () => { + for (const { beats } of Object.values(RHYTHM)) { + const xs = beatPoints(280, beats).split(' ').map((p) => Number(p.split(',')[0])); + expect(xs[0]).toBe(280); + expect(xs[xs.length - 1]).toBe(560); + xs.forEach((x, i) => { if (i) expect(x).toBeGreaterThanOrEqual(xs[i - 1]); }); + const ys = beatPoints(0, beats).split(' ').map((p) => Number(p.split(',')[1])); + ys.forEach((y) => { expect(y).toBeGreaterThanOrEqual(0); expect(y).toBeLessThanOrEqual(50); }); + } + }); +}); + +describe('the monitor', () => { + it('draws each band in its color and at its speed, and a flatline when down', () => { + for (const band of ['steady', 'fast', 'critical'] as const) { + const { container } = render(); + const svg = container.querySelector('svg.ekg-live') as SVGElement; + expect(svg.style.animationDuration).toBe(`${RHYTHM[band].seconds}s`); + expect(container.querySelector('polyline')!.getAttribute('stroke')).toBe(BAND_COLOR[band]); + expect(container.querySelector('polyline')!.getAttribute('points')).toBe(beatPoints(0, RHYTHM[band].beats)); + cleanup(); + } + const { container } = render(); + expect(container.querySelector('svg.ekg-live')).toBeNull(); + expect(container.querySelector('line')!.getAttribute('stroke')).toBe('var(--danger)'); + }); + + it('holds still with reduced motion', () => { + const { container } = render(); + expect(container.querySelector('style')!.textContent).toMatch(/prefers-reduced-motion: reduce\) \{ \.ekg-live \{ animation: none; \}/); + }); + + it('tells a screen reader how it looks, never a number', () => { + render(); + expect(screen.getByRole('img', { name: 'Heart monitor: weak, uneven heartbeat' })).toBeTruthy(); + }); + + it("follows another player's health as others see it", () => { + const token = { id: 1, name: 'Razor', x: 0, y: 0, z: 0, shape: 'rhombus', owner: 'RAZOR', hp_max: 20 } as never; + const shown = (hp: number) => { + const { container } = render(); + const band = container.querySelector('[data-band]')!.getAttribute('data-band'); + cleanup(); + return band; + }; + expect([shown(18), shown(9), shown(3), shown(0)]).toEqual(['steady', 'fast', 'critical', 'down']); + }); + + it('beats the same way in the editing panel and on the stream', () => { + const token = (hp: number) => ({ id: 1, name: 'Razor', x: 0, y: 0, z: 0, shape: 'rhombus', owner: 'RAZOR', hp_current: hp, hp_max: 20 } as never); + const inPanel = (hp: number) => { + const { container } = render( {}} />); + const band = container.querySelector('[data-band]')!.getAttribute('data-band'); + cleanup(); + return band; + }; + const onStream = (hp: number) => { + const { container } = render(); + const band = container.querySelector('[data-band]')!.getAttribute('data-band'); + cleanup(); + return band; + }; + expect([inPanel(18), inPanel(9), inPanel(3), inPanel(0)]).toEqual(['steady', 'fast', 'critical', 'down']); + expect([onStream(18), onStream(9), onStream(3), onStream(0)]).toEqual(['steady', 'fast', 'critical', 'down']); + }); +}); diff --git a/frontend/src/components/healthBands.ts b/frontend/src/components/healthBands.ts new file mode 100644 index 00000000..1fde336f --- /dev/null +++ b/frontend/src/components/healthBands.ts @@ -0,0 +1,55 @@ +// How hurt a token is, as a band: the heart monitor's color AND its rhythm come from it. +// +// Other players never see a number for someone else's health (HealthReviewPanel), so the +// monitor has to say it: green and steady over half, yellow and twice as fast at half or +// less, red, fast and uneven at a quarter or less, a red flatline when down. Color and +// rhythm use the same thresholds, so they always agree. Every system's monitor draws from +// this, the built-in ones included. + +export type HealthBand = 'steady' | 'fast' | 'critical' | 'down'; + +/** The band for a current and max. No max reads as badly hurt, as the color always has. */ +export const bandOf = (current: number, max: number): HealthBand => { + if (current <= 0) return 'down'; + const pct = max > 0 ? Math.max(0, Math.min(1, current / max)) : 0; + return pct > 0.5 ? 'steady' : pct > 0.25 ? 'fast' : 'critical'; +}; + +export const BAND_COLOR: Record = { + steady: 'var(--green)', + fast: 'var(--warning)', + critical: 'var(--danger)', + down: 'var(--danger)', +}; + +/** Words for a screen reader: what the picture shows, still without a number. */ +export const BAND_WORDS: Record = { + steady: 'steady heartbeat', + fast: 'fast heartbeat', + critical: 'weak, uneven heartbeat', + down: 'flatline', +}; + +/** + * The beats in one 280-wide stretch of the trace, as [where it starts, how strong]. The + * trace is two stretches scrolled by half its width, so what one holds repeats seamlessly. + * Over half: the one even beat the monitor has always drawn. Half or less: twice as many. + * A quarter or less: fast, uneven, and some barely there. Seconds is one full scroll. + */ +export const RHYTHM: Record, { seconds: number; beats: [number, number][] }> = { + steady: { seconds: 2.4, beats: [[27, 1]] }, + fast: { seconds: 2.4, beats: [[20, 1], [160, 1]] }, + // A beat is 60 wide: each starts after the last has ended, and the last ends by 280. + critical: { seconds: 2, beats: [[10, 0.8], [78, 0.45], [150, 0.75], [214, 0.3]] }, +}; + +/** One beat's shape, from where it starts: a small bump, the spike, a trailing wave. Baseline 25. */ +const BEAT: [number, number][] = [[0, 25], [7, 20], [15, 25], [22, 25], [29, 5], [33, 45], [37, 5], [41, 25], [53, 30], [60, 25]]; + +const fmt = (n: number) => String(Number(n.toFixed(1))); + +/** The polyline for one stretch starting at `offset`, with `beats` in it. */ +export const beatPoints = (offset: number, beats: [number, number][]): string => + [[offset, 25], ...beats.flatMap(([x, strength]) => BEAT.map(([bx, by]) => [offset + x + bx, 25 + (by - 25) * strength])), [offset + 280, 25]] + .map(([x, y]) => `${fmt(x)},${fmt(y)}`) + .join(' ');