diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dec4588..393ebdd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **The builder's CONDITIONS page.** A new page between NPCS and TRY IT lists the ten standard + conditions with on/off switches and the system's own below them. Each can be renamed (the + standard name shows until you type one), given a short label for its chip, an icon (24 drawn for + CITY_NET that follow the theme, or a picture you upload), a description, an end (when removed, or + after a number of rounds) and modifiers on all rolls, a stat or a formula. + CONDITION adds one of + your own, up to 60 in all, and deleting one asks first. Putting a standard condition back as it + was saves nothing, as with WORDS. Conditions on tokens come next. + - **Conditions as part of a game system.** A custom system now carries its conditions: ten standard ones every system starts with (Blinded, Bleeding, Poisoned, Prone, Stunned, Grappled, Frightened, Unconscious, Restrained, Exhausted), each of which can be renamed, described, given diff --git a/README.md b/README.md index cb3267e3..e52a3af3 100644 --- a/README.md +++ b/README.md @@ -709,6 +709,8 @@ CITY_NET/ │ │ │ ├── SheetPage.tsx # CHARACTER SHEET: automatic until CUSTOMIZE copies the starter; a tree of tabs, sections and fields to move and edit, who sees and who changes each field, a tray of stats not placed yet, and a live preview. Also edits NPCS' stat block of its own (GM-only preview) │ │ │ ├── SheetPreview.tsx # The sheet designer's live preview, drawn by the real SheetRenderer, as its owner, the GM and everyone else see it │ │ │ ├── NpcsPage.tsx # NPCS: the stat block (the character sheet, or one of its own) and TIERS, each box a number, a formula with @level or dice, typed text kept as typed, a TRY IT roll at a level +│ │ │ ├── ConditionsPage.tsx # CONDITIONS: the standard set with switches and a system's own (+ CONDITION, DELETE after asking), up to 60; each one's name (the standard name as placeholder), chip label, icon (24 drawn, or UPLOAD), description, how it ends (when removed or after rounds) and modifiers on all rolls, a stat or a formula, typed numbers kept as typed +│ │ │ ├── ConditionIcon.tsx # A condition's icon: one drawn for CITY_NET, stroked in the theme's color, or an uploaded picture through only; anything unknown draws the target │ │ │ ├── TryItPage.tsx # TRY IT: the draft as a player would meet it, unsaved changes included. A made-up character on its own sheet with formulas worked out as you type, its health on a pretend token in the HEALTH folder's own panels with what others see, and an NPC rolled from a tier. Saves nothing but SAVE AS THE SAMPLE CHARACTER │ │ │ ├── ControlledNpcSheetWindow.tsx # A friendly NPC's sheet for the player the GM gave it to: read-only, with rolls made for the NPC through the server │ │ │ ├── UpdateModal.tsx # Draggable update notification modal (shown on admin login when update available; Update Now / Remind Me Later / Skip Version; docker-aware) @@ -780,6 +782,7 @@ CITY_NET/ │ │ │ ├── StatsRulesPage.test.tsx # Stats in groups with their SAMPLE column, formulas with live values and mistakes, and tables with TRY IT │ │ │ ├── SheetPage.test.tsx # Automatic or customized, the tree of tabs, sections and fields, moving and removing them (sections going to a tab you choose, the last tab making one page), and each field's settings │ │ │ ├── NpcsPage.test.tsx # The stat block shared or its own, tiers whose boxes keep what was typed, and TRY IT rolling one, all through the server's own code +│ │ │ ├── ConditionsPage.test.tsx # Switches storing only "off", renaming and putting back, icons drawn or uploaded (a refusal said), ends and rounds, modifiers typed negative, a system's own added, named, deleted after asking, and + CONDITION stopping at 60 │ │ │ ├── TryItPage.test.tsx # The made-up character and its formulas, SAVE AS THE SAMPLE (absent with nowhere to save), RESET, health on a pretend token under each model, both maximums, and an NPC rolled from a tier │ │ │ ├── ControlledNpcSheetWindow.test.tsx # A friendly NPC's sheet read-only for the player it was given to, fetched with their own login, rolling as the NPC │ │ │ ├── SheetAttackPanel.attackAs.test.tsx # ATTACK AS: a player with a friendly NPC given to them, the GM with an NPC near the target, its weapons and LUCK, firing as it @@ -923,6 +926,8 @@ CITY_NET/ │ │ │ ├── sheetDesigner.ts # CHARACTER SHEET as logic: a system's own sheet read from its definition and written back - tabs, sections, fields, placement, the tray, and the problems that stop it saving │ │ │ ├── publicLines.ts # What everyone else sees of a custom system's character: the sheet's public lines, for ID.EXE's INFO and the sheet preview's EVERYONE ELSE │ │ │ ├── npcs.ts # NPCS as logic: the stat block (shared or its own), tiers and their boxes (number, formula with @level, or dice), the server's limits, and a rolled box as text +│ │ │ ├── conditions.ts # CONDITIONS as logic: the standard set and a system's own read from a definition and written back storing only what differs (a standard one put back leaves nothing), the count against 60, new and deleted ones, and what a modifier may name; mirrors the server's conditions.js +│ │ │ ├── conditionIcons.ts # The 24 condition icons drawn for CITY_NET as path data on a 24-pixel grid, ours to use (the four first sketched too close to Feather's redrawn), and telling a drawn icon from an uploaded one │ │ │ ├── tryIt.ts # TRY IT as logic: the made-up character from the sample, typed values, SAVE AS THE SAMPLE, the pretend token and its maximum │ │ │ ├── examples.ts # The examples' and starters' cards (health, advancement in the system's own XP word, stats, formulas, distance), the copy's suggested name, and locking a page while one is looked at (every control but tabs, things opening out and marked views) │ │ │ ├── templates/ @@ -942,6 +947,8 @@ CITY_NET/ │ │ │ ├── statsRules.test.ts # Stats, samples, formulas and tables written back, ids from first names and kept after, held to the server's check and engine │ │ │ ├── sheetDesigner.test.ts # The sheet designer's every step (customize, the tray, moving and removing tabs, sections and fields, attack numbers never EVERYONE), held to the server's check │ │ │ ├── npcs.test.ts # The stat block and tiers written back, held to the server's check and rolled by its tierRolls +│ │ │ ├── conditions.test.ts # The standard set and what the game offers held to the server's own conditionsOf, each change storing only what differs, a system's own added, renamed and deleted, the limit of 60, and what a modifier may name +│ │ │ ├── conditionIcons.test.tsx # The 24 icons are the server's, path data alone, none of Feather's paths, stroked in currentColor and never filled; an upload drawn through ; anything unknown drawn as the target │ │ │ ├── tryIt.test.ts # The made-up character, typed values, SAVE AS THE SAMPLE and RESET, the pretend token and its maximum │ │ │ ├── examples.test.ts # The examples' and starters' card facts read off the server's own definitions, XP in the system's word, and locking a page but for browsing │ │ │ ├── customTemplates.test.tsx # A published custom system's sheet drawn by the ordinary renderer from the server's render copy, the generic template standing in until it loads diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index 678aee9e..21ae4c2c 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -13,6 +13,8 @@ import { FeaturesPage } from './FeaturesPage'; import { StatsRulesPage } from './StatsRulesPage'; import { SheetPage } from './SheetPage'; import { NpcsPage } from './NpcsPage'; +import { ConditionsPage } from './ConditionsPage'; +import { CONDITION_ICONS } from '../sheets/conditionIcons'; import { TryItPage } from './TryItPage'; // The system builder (4a2b): it takes over the whole window, with no map. A sidebar down the left @@ -115,6 +117,7 @@ const Icons: Record} {ready && page === 'sheet' && } {ready && page === 'npcs' && } + {ready && page === 'conditions' && } {ready && page === 'try' && } diff --git a/frontend/src/components/ConditionIcon.tsx b/frontend/src/components/ConditionIcon.tsx new file mode 100644 index 00000000..06c7d239 --- /dev/null +++ b/frontend/src/components/ConditionIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { CONDITION_ICONS, isDrawnIcon, isUploadedIcon } from '../sheets/conditionIcons'; + +// A condition's icon (4e1b): one drawn for CITY_NET, stroked in the theme's color, or a picture the +// GM uploaded, drawn through only, which is what makes an uploaded SVG safe (as currency +// icons and battle maps). Anything else draws the target, so a broken reference still shows a mark. + +export function ConditionIcon({ icon, size = 18, title }: { icon: string; size?: number; title?: string }) { + if (isUploadedIcon(icon)) { + return {title; + } + const paths = CONDITION_ICONS[isDrawnIcon(icon) ? icon : 'target']; + return ( + + {paths.map((d) => )} + + ); +} diff --git a/frontend/src/components/ConditionsPage.tsx b/frontend/src/components/ConditionsPage.tsx new file mode 100644 index 00000000..3550c36e --- /dev/null +++ b/frontend/src/components/ConditionsPage.tsx @@ -0,0 +1,214 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { Definition, systemsApi } from '../sheets/systemsApi'; +import { + conditionList, conditionCount, withCondition, withNewCondition, withoutCondition, modifierTargets, + LIMITS, ALL_ROLLS, STANDARD, type Listed, +} from '../sheets/conditions'; +import { CONDITION_ICON_IDS, isUploadedIcon } from '../sheets/conditionIcons'; +import { ConditionIcon } from './ConditionIcon'; + +// The builder's CONDITIONS page (4e1b; approved mockup docs/mockups/builder-conditions.html, +// 2026-10-09): what can happen to a character besides losing health. The standard set, each with +// a switch, edited or turned off; the system's own, added and deleted; each one's name, chip label, +// icon (drawn, or uploaded), description, how it ends, and its modifiers. What it reads and writes +// is sheets/conditions.ts; every change goes through the builder's `edit`. + +interface Props { + definition: Definition; + edit: (next: Definition) => void; + /** For uploading an icon; without it only the drawn icons are offered. */ + api?: ReturnType; +} + +const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; +const why: React.CSSProperties = { fontSize: 12, lineHeight: 1.45, opacity: 0.85, margin: 0 }; +const btn: React.CSSProperties = { fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '4px 9px' }; +const input: React.CSSProperties = { + background: 'var(--black)', color: 'var(--green)', border: '1px solid var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '4px 6px', minWidth: 0, boxSizing: 'border-box', +}; +const row: React.CSSProperties = { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }; + +/** + * A whole number box that keeps what is typed: a lone minus sign, or a field cleared to type + * again, stays as typed rather than becoming 0 under the cursor. + */ +function WholeBox({ aria, value, min, max, width = 64, onChange }: { aria: string; value: number; min: number; max: number; width?: number; onChange: (n: number) => void }) { + const [text, setText] = useState(String(value)); + useEffect(() => { setText((t) => (Number(t) === value ? t : String(value))); }, [value]); + return ( + { + setText(e.target.value); + if (/^-?\d+$/.test(e.target.value.trim())) onChange(Math.max(min, Math.min(max, parseInt(e.target.value, 10)))); + }} /> + ); +} + +function Switch({ on, label, onChange }: { on: boolean; label: string; onChange: (on: boolean) => void }) { + return ( + + ); +} + +function Detail({ c, definition, edit, api }: Props & { c: Listed }) { + const [confirm, setConfirm] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const fileRef = useRef(null); + const base = STANDARD.find((s) => s.id === c.id); + const targets = modifierTargets(definition); + const set = (patch: Parameters[2]) => edit(withCondition(definition, c.id, patch)); + const stored = (definition.conditions as Record> | undefined)?.[c.id] ?? {}; + const typed = (key: 'name' | 'short') => (typeof stored[key] === 'string' ? stored[key] as string : (base ? '' : c[key])); + + const upload = async (file: File | undefined) => { + if (!file || !api) return; + setUploading(true); + const r = await api.uploadIcon(file, 'condition'); + setUploading(false); + if (!r.ok) { setUploadError(r.error); return; } + setUploadError(null); + set({ icon: r.value.icon }); + }; + + return ( +
+
+ {base ? 'STANDARD CONDITION' : 'ONE OF THIS SYSTEM\'S OWN'} + {base + ? {c.on ? 'On: offered for tokens.' : 'Off: not offered for tokens.'} + : confirm + ? + Delete {c.name || 'this condition'}? + + + + : } +
+
+ + set({ name: e.target.value })} /> + + set({ short: e.target.value.toUpperCase() })} /> + ICON +
+
+ {CONDITION_ICON_IDS.map((id) => ( + + ))} +
+
+ {isUploadedIcon(c.icon) && ( + + + + )} + {api && <> + + { upload(e.target.files?.[0]); e.target.value = ''; }} /> + PNG, WebP or SVG, a quarter of a megabyte. Kept in its own colors; the drawn ones follow the theme. + } +
+ {uploadError && {uploadError}} +
+ +