character-sheet.tsx
1 import type React from "react"; 2 3 import { useRef, useState } from "react"; 4 5 import { cn } from "./cn"; 6 import { GrainOverlay } from "./grain-overlay"; 7 import { PortraitCanvas, type PortraitCanvasHandle } from "./portrait-canvas"; 8 9 const SHEETS_KEY = "dungeon-motion-sheets"; 10 type SheetData = Record<string, boolean | string>; 11 12 function getSheets(): Record<string, SheetData> { 13 try { 14 return JSON.parse(localStorage.getItem(SHEETS_KEY) || "{}"); 15 } catch { 16 return {}; 17 } 18 } 19 20 function serializeForm(): SheetData { 21 const form = document.querySelector("form"); 22 if (!form) return {}; 23 const data: SheetData = {}; 24 for (const el of form.elements) { 25 const field = el as 26 | HTMLInputElement 27 | HTMLSelectElement 28 | HTMLTextAreaElement; 29 if (!field.name) continue; 30 data[field.name] = 31 field instanceof HTMLInputElement && field.type === "checkbox" 32 ? field.checked 33 : field.value; 34 } 35 const portrait = localStorage.getItem("dungeon-motion-portrait"); 36 if (portrait) data["__portrait__"] = portrait; 37 return data; 38 } 39 40 function applySheet(data: SheetData) { 41 const form = document.querySelector("form"); 42 if (!form) return; 43 for (const el of form.elements) { 44 const field = el as 45 | HTMLInputElement 46 | HTMLSelectElement 47 | HTMLTextAreaElement; 48 if (!field.name || !(field.name in data)) continue; 49 if (field instanceof HTMLInputElement && field.type === "checkbox") { 50 field.checked = data[field.name] as boolean; 51 } else { 52 field.value = data[field.name] as string; 53 } 54 } 55 if (typeof data["__portrait__"] === "string") { 56 localStorage.setItem("dungeon-motion-portrait", data["__portrait__"]); 57 } 58 form.dispatchEvent(new Event("input", { bubbles: true })); 59 form 60 .querySelector('input[type="checkbox"]') 61 ?.dispatchEvent(new Event("change", { bubbles: true })); 62 } 63 64 function SheetControls() { 65 const [names, setNames] = useState<string[]>(() => Object.keys(getSheets())); 66 const [saved, setSaved] = useState(false); 67 68 return ( 69 <div className="fixed top-3 right-3 print:hidden flex items-center gap-1 z-50"> 70 <button 71 className="text-[10px] tracking-widest uppercase text-stone-400/70 dark:text-stone-500/70 hover:text-stone-600 dark:hover:text-stone-300 focus-visible:text-stone-600 dark:focus-visible:text-stone-300 focus-visible:underline underline-offset-2 outline-none transition-colors cursor-pointer" 72 onClick={(e) => { 73 e.preventDefault(); 74 const data = serializeForm(); 75 const name = (data["char-name"] as string | undefined)?.trim(); 76 if (!name) return; 77 const sheets = getSheets(); 78 sheets[name] = data; 79 localStorage.setItem(SHEETS_KEY, JSON.stringify(sheets)); 80 setNames(Object.keys(sheets)); 81 setSaved(true); 82 setTimeout(() => setSaved(false), 1500); 83 }} 84 type="button" 85 > 86 [ {saved ? "Saved" : "Save"} ] 87 </button> 88 <span className="text-stone-300/40 dark:text-stone-600/40 text-[10px]"></span> 89 <select 90 className="text-[10px] tracking-widest uppercase text-stone-400/70 dark:text-stone-500/70 hover:text-stone-600 dark:hover:text-stone-300 focus-visible:text-stone-600 dark:focus-visible:text-stone-300 focus-visible:underline underline-offset-2 outline-none transition-colors cursor-pointer bg-transparent appearance-none" 91 onChange={(e) => { 92 const name = e.currentTarget.value; 93 if (!name) return; 94 e.currentTarget.value = ""; 95 const sheets = getSheets(); 96 if (sheets[name]) applySheet(sheets[name]); 97 }} 98 onFocus={() => setNames(Object.keys(getSheets()))} 99 value="" 100 > 101 <option value="">[ Load ]</option> 102 {names.map((n) => ( 103 <option key={n} value={n}> 104 {n} 105 </option> 106 ))} 107 </select> 108 </div> 109 ); 110 } 111 112 export function CharacterSheet() { 113 return ( 114 <form autoComplete="off" className="min-h-screen"> 115 <GrainOverlay /> 116 <SheetControls /> 117 118 <div className="relative max-w-6xl mx-auto px-6 print:max-w-none print:mx-0 print:px-0"> 119 <div className="grid grid-cols-1 md:grid-cols-2 print:grid-cols-2 gap-8 md:gap-0 print:gap-0"> 120 <LeftPage /> 121 <RightPage /> 122 </div> 123 </div> 124 </form> 125 ); 126 } 127 128 function LeftPage() { 129 return ( 130 <div className="md:pr-8 print:pr-8 md:border-r print:border-r border-dashed border-stone-300 dark:border-stone-600 print:border-stone-400 space-y-6"> 131 <div className="flex gap-6"> 132 <div className="flex-1 min-w-0 space-y-2"> 133 <InlineField label="Name" name="char-name" /> 134 <InlineField label="Look" name="look" /> 135 <InlineField label="Background" name="background" /> 136 <div className="flex gap-4"> 137 <InlineField label="Level" name="level" /> 138 <InlineField label="XP" name="xp" /> 139 </div> 140 <CombatShapes className="mt-4" /> 141 </div> 142 <PortraitFrame /> 143 </div> 144 <StatBlockWithDebilities /> 145 <InlineField label="Instinct" name="instinct" /> 146 <BondLines /> 147 </div> 148 ); 149 } 150 151 function PortraitFrame() { 152 const canvasRef = useRef<PortraitCanvasHandle>(null); 153 const [hasStrokes, setHasStrokes] = useState(false); 154 155 return ( 156 <div className="shrink-0 w-36 self-stretch relative"> 157 <div className="absolute inset-0 border-[3px] border-double border-stone-700 dark:border-stone-300 print:border-stone-800 rounded-xl [corner-shape:superellipse(-1.1)] overflow-hidden"> 158 <PortraitCanvas onStrokesChange={setHasStrokes} ref={canvasRef} /> 159 </div> 160 {hasStrokes && ( 161 <button 162 className="absolute -top-2 -right-2 p-0.5 text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 print:hidden" 163 onClick={() => canvasRef.current?.clear()} 164 title="Clear portrait" 165 type="button" 166 > 167 <svg 168 className="size-3.5" 169 fill="none" 170 stroke="currentColor" 171 strokeLinecap="round" 172 strokeLinejoin="round" 173 strokeWidth="2" 174 viewBox="0 0 24 24" 175 > 176 <path d="M18 6 6 18M6 6l12 12" /> 177 </svg> 178 </button> 179 )} 180 </div> 181 ); 182 } 183 184 const STAT_PAIRS: [StatDef, StatDef, string][] = [ 185 [ 186 { abbr: "STR", name: "Strength" }, 187 { abbr: "DEX", name: "Dexterity" }, 188 "weakened", 189 ], 190 [ 191 { abbr: "INT", name: "Intelligence" }, 192 { abbr: "WIS", name: "Wisdom" }, 193 "dazed", 194 ], 195 [ 196 { abbr: "CON", name: "Constitution" }, 197 { abbr: "CHA", name: "Charisma" }, 198 "miserable", 199 ], 200 ]; 201 202 interface StatDef { 203 abbr: string; 204 name: string; 205 } 206 207 function StatBlockWithDebilities() { 208 return ( 209 <div> 210 <p className="font-hand text-stone-500 dark:text-stone-400 mb-3"> 211 <span className="font-serif font-bold tracking-wider text-stone-700 dark:text-stone-300 text-sm"> 212 Attributes 213 </span>{" "} 214 Assign +2, +1, +1, +0, +0, −1. Debility marked → roll with disadvantage. 215 </p> 216 <div className="flex gap-3 print:gap-2"> 217 {STAT_PAIRS.map(([left, right, debility]) => ( 218 <StatPairWithFork 219 debility={debility} 220 key={debility} 221 left={left} 222 right={right} 223 /> 224 ))} 225 </div> 226 </div> 227 ); 228 } 229 230 function StatPairWithFork({ 231 debility, 232 left, 233 right, 234 }: { 235 debility: string; 236 left: StatDef; 237 right: StatDef; 238 }) { 239 return ( 240 <div className="flex-1"> 241 <div className="flex gap-1.5 print:gap-1"> 242 <StatBox stat={left} /> 243 <StatBox stat={right} /> 244 </div> 245 {/* Bracket fork with debility circle */} 246 <div className="relative flex items-start justify-center mt-0"> 247 {/* Left arm of bracket */} 248 <div className="absolute left-1/4 top-0 w-[calc(25%)] h-3 border-l border-b border-stone-400 dark:border-stone-600 print:border-stone-500" /> 249 {/* Right arm of bracket */} 250 <div className="absolute right-1/4 top-0 w-[calc(25%)] h-3 border-r border-b border-stone-400 dark:border-stone-600 print:border-stone-500" /> 251 {/* Center stem */} 252 <div className="w-px h-4 bg-stone-400 dark:bg-stone-600 print:bg-stone-500 mt-3" /> 253 </div> 254 {/* Debility circle checkbox */} 255 <div className="flex flex-col items-center -mt-0.5"> 256 <input 257 className="size-4 rounded-full" 258 name={`debility-${debility}`} 259 type="checkbox" 260 /> 261 <span className="text-[10px] text-stone-500 dark:text-stone-400 mt-0.5"> 262 {debility} 263 </span> 264 </div> 265 </div> 266 ); 267 } 268 269 function StatBox({ stat }: { stat: StatDef }) { 270 return ( 271 <div className="flex-1 relative border-2 border-stone-400 dark:border-stone-500 print:border-stone-500 rounded-lg [corner-shape:superellipse(-1.1)] pt-4 pb-4 px-1.5 print:px-1 text-center"> 272 <div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-stone-50 dark:bg-stone-900 print:bg-white px-1 text-[10px] tracking-wider text-stone-700 dark:text-stone-300 leading-none whitespace-nowrap"> 273 {stat.name} 274 </div> 275 <input 276 className="w-full text-center font-hand text-3xl print:text-2xl bg-transparent text-stone-800 dark:text-stone-200 focus:outline-none border-none" 277 name={`stat-${stat.abbr.toLowerCase()}`} 278 placeholder="+0" 279 type="text" 280 /> 281 <div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-[calc(50%+2px)] bg-stone-50 dark:bg-stone-900 print:bg-white px-1 text-[9px] text-stone-500 dark:text-stone-400 leading-none whitespace-nowrap"> 282 ({stat.abbr}) 283 </div> 284 </div> 285 ); 286 } 287 288 function CombatShapes({ className }: { className?: string }) { 289 return ( 290 <div 291 className={cn( 292 "flex justify-center items-end justify-self-start gap-1", 293 className, 294 )} 295 > 296 <ShapedStat 297 className="-translate-y-1.5 -translate-x-px" 298 label="HP" 299 name="hp" 300 viewBox="0 -20 250 270" 301 > 302 <path 303 d="m239.61 70.33c-1.91 10.33-3.26 19.63-9.43 30.62-6.77 12.06-16.44 22.58-26.07 36.01-6.17 5.51-11.21 13.35-19.84 19.85-5.65 4.95-11.3 10.26-16.34 12.96-11.03 8.74-21.43 18.43-37.13 36.18-4.49-4.49-7.82-10.71-12.81-15.25-12.48-11.03-21.41-22.94-35.18-32.34l0.48 0.48 0.58 0.92-2.29-1.4c-7.91-4.99-17.08-14.48-23.75-20.09-8.17-7.16-14.05-16.5-22.01-26.78 8.35 7.94 14.92 16.78 23.41 22.99l-0.82-1.72c-10.4-11.6-21.33-20.7-30.81-36.3-5.65-8.89-7.09-17.49-8.28-29.55-0.38-8.89 2.01-17.83 5.29-27.81 1.86-9.98 9.07-18.07 18.94-23.53 12.48-7.6 21.41-13.11 31.38-13.2 3.67-0.04 7.29 0.57 10.91 0.9 12.96 3.43 25.24 10.1 32.91 18.99 3.42 3.97 6.99 7.74 8.76 10.69-1.71 2.02-2.53 4.32-2.67 7.22l1.19-0.19c1.39-1.58 1.68-3.11 2.69-4.51 2.2 1.01 3.86-0.47 5.72-5.31 1.4-3.17 4.54-6.07 8.06-9.5 10.98-10.48 20.41-15.58 30.33-16.5 10.67-1.93 21.53-2.17 31.57 0.33 8.58 1.3 14.23 6.61 20.7 13.28 4.9 4.49 8.32 10 11.75 15.56 3.67 6.77 3.62 27.38 2.76 37z" 304 strokeWidth="7" 305 /> 306 </ShapedStat> 307 <ShapedStat 308 className="-translate-y-1" 309 label="Armor" 310 name="armor" 311 viewBox="28 20 160 178" 312 > 313 <path 314 d="m164.9 49.28-1.15 0.21 0.11-4.2-1.05-0.92c-7.93-0.92-15.09-1.12-20.79-3.86l-3.94-3.16 0.63-2.26-0.73-0.92-0.94 0.31-0.42 1.64 1.05 3.79-0.42 0.41-4.25-2.06c-11.05-4.35-21.8-10.13-29.28-15.1l-1.45 1.12 0.21-1.53c-12.58 8.65-25.82 16.79-42.33 18.95l-7.48 1.95-7.93 0.21c-3.61 10.15-2.46 28.15 0.08 43.92 1.45 9.01 2.39 13.67 3.23 18.23l-3.23 6.18-0.52 2.74 1.65-0.31 1.75-0.61 3.5 2.55 7.93 22.14-0.32 10.46c6.32 0.62 9.93 3.88 15.73 11.78l-1.26 0.2 1.46 1.53c8.03 6.18 16.5 15.43 28.87 23.78 12.47-7.84 23.22-19.23 31.25-29.9 3.83-4.25 8.59-7.7 13.83-8.32-1.25-8.04-0.72-12.04 3.42-20.44 6.85-14.12 9.6-28.45 12.78-54.78 0.84-6.23 0.84-15.98-0.01-23.73z" 315 strokeWidth="5" 316 /> 317 </ShapedStat> 318 <ShapedStat label="Damage" name="damage" viewBox="30 0 196 256"> 319 <path 320 d="M 127.27,18.19 L 117.72,33.06 L 108.69,46.7 L 102.81,54.19 L 101.06,56.91 L 100.04,57.96 L 98.82,60.06 L 94.72,65.38 L 94.42,65.55 L 94.62,66.03 L 89.07,73.82 L 83.52,82.62 L 81.78,85.01 L 78.35,88.91 L 77.86,90.33 L 73.22,97.51 L 72.92,97.78 L 72.69,98.62 L 65.11,109.45 L 64.62,110.39 L 56.34,122.23 L 51.61,128.23 L 53.12,129.01 C 54.93,130.81 56.71,133.84 58.66,136.46 C 61.97,140.72 64.71,146.26 67.95,151.22 C 70.08,155.19 73.64,159.48 76.57,164.05 C 79.13,167.21 81.68,171.47 84.45,175.3 C 87.01,177.92 89.16,182.18 91.94,185.04 L 92.83,186.02 L 96.11,190.62 L 97.78,192.55 L 103.92,201.11 L 105.01,202.33 L 116.11,218.26 L 119.01,222.73 L 120.72,225.86 L 125.69,235.06 L 127.81,237.12 L 138.91,218.26 L 143.88,211.01 L 145.07,209.42 L 149.61,202.63 L 153.74,197.77 L 161.02,187.69 L 162.41,186.2 L 169.01,176.46 L 173.81,169.21 L 176.81,165.7 L 177.51,164.18 L 183.65,153.72 L 186.03,150.56 L 195.16,137.51 L 198.92,132.91 L 199.78,132.23 L 203.44,127.86 L 191.72,111.42 L 190.92,109.62 L 189.9,108.71 L 189.76,107.73 L 188.9,107.09 L 184.53,100.33 L 178.19,91.13 L 176.31,88.71 L 175.22,87.56 L 172.15,82.6 L 167.18,76.7 L 164.11,72.23 L 161.14,68.72 L 155.42,61.23 L 152.87,57.22 L 145.42,47.55 L 144.56,45.86 L 136.21,33.82 L 135.09,32.4 L 132.47,27.44 L 127.27,18.19 Z" 321 strokeWidth="6" 322 /> 323 </ShapedStat> 324 </div> 325 ); 326 } 327 328 function ShapedStat({ 329 children, 330 className, 331 label, 332 name, 333 viewBox = "0 0 48 48", 334 }: { 335 children: React.ReactNode; 336 className?: string; 337 label: string; 338 name: string; 339 viewBox?: string; 340 }) { 341 return ( 342 <div className="flex flex-col items-center"> 343 <span className="text-xs font-bold tracking-wider text-stone-500 dark:text-stone-400 mb-0.5"> 344 {label} 345 </span> 346 <div className="relative size-28"> 347 <svg 348 className="absolute inset-0 w-full h-full text-stone-400 dark:text-stone-500 print:text-stone-500" 349 fill="none" 350 stroke="currentColor" 351 strokeLinejoin="round" 352 viewBox={viewBox} 353 > 354 {children} 355 </svg> 356 <input 357 className={cn( 358 "absolute inset-0 w-full h-full text-center font-hand text-5xl bg-transparent text-stone-800 dark:text-stone-200 focus:outline-none -translate-x-1.5", 359 className, 360 )} 361 name={name} 362 type="text" 363 /> 364 </div> 365 </div> 366 ); 367 } 368 369 function RightPage() { 370 return ( 371 <div className="md:pl-8 print:pl-8 space-y-5"> 372 <SuppliesTrack /> 373 374 <div> 375 <SectionLabel>Gear</SectionLabel> 376 <div className="space-y-1.5"> 377 {Array.from({ length: 5 }).map((_, i) => ( 378 <BlankLine key={i} name={`possession-${i}`} /> 379 ))} 380 </div> 381 </div> 382 383 <AcquiredMoves /> 384 </div> 385 ); 386 } 387 388 function BondLines() { 389 return ( 390 <div> 391 <SectionLabel>Bonds</SectionLabel> 392 <div className="space-y-1.5"> 393 {Array.from({ length: 4 }).map((_, i) => ( 394 <BlankLine key={i} name={`bond-${i}`} /> 395 ))} 396 </div> 397 </div> 398 ); 399 } 400 401 function SuppliesTrack() { 402 const supplies = [ 403 { count: 3, name: "Rations" }, 404 { count: 3, name: "Bandages" }, 405 { count: 3, name: "Ammo" }, 406 { count: 5, name: "Adventuring Gear" }, 407 ]; 408 409 return ( 410 <div> 411 <SectionLabel>Supplies</SectionLabel> 412 <div className="space-y-1.5"> 413 {supplies.map((s) => ( 414 <div className="flex items-center gap-2" key={s.name}> 415 <span className="text-sm text-stone-700 dark:text-stone-300 shrink-0 whitespace-nowrap"> 416 {s.name} 417 </span> 418 <div className="flex gap-1"> 419 {Array.from({ length: s.count }).map((_, i) => ( 420 <input 421 className="size-3.5" 422 key={i} 423 name={`supply-${s.name.toLowerCase().replaceAll(" ", "-")}-${i}`} 424 type="checkbox" 425 /> 426 ))} 427 </div> 428 </div> 429 ))} 430 </div> 431 </div> 432 ); 433 } 434 435 function AcquiredMoves() { 436 return ( 437 <div> 438 <SectionLabel>Moves</SectionLabel> 439 <div className="space-y-2"> 440 {Array.from({ length: 8 }).map((_, i) => ( 441 <div className="flex items-start gap-2" key={i}> 442 <input 443 className="size-4 shrink-0 mt-px" 444 name={`move-check-${i}`} 445 type="checkbox" 446 /> 447 <textarea 448 className="flex-1 bg-transparent border-b border-stone-300 dark:border-stone-600 print:border-stone-400 font-hand text-base text-stone-800 dark:text-stone-200 focus:outline-none focus:border-stone-500 resize-none overflow-visible py-1 print:min-h-10 first-line:font-serif leading-none field-sizing-content" 449 name={`move-name-${i}`} 450 onChange={(event) => { 451 const textarea = event.currentTarget; 452 textarea.scrollTop = 0; 453 }} 454 /> 455 </div> 456 ))} 457 </div> 458 </div> 459 ); 460 } 461 462 // -- Shared Utilities -- 463 464 function InlineField({ 465 className, 466 label, 467 name, 468 }: { 469 className?: string; 470 label: string; 471 name: string; 472 }) { 473 return ( 474 <label className={cn("flex items-baseline gap-2 flex-1", className)}> 475 <span className="font-bold tracking-wide text-stone-700 dark:text-stone-300 shrink-0 whitespace-nowrapt text-sm"> 476 {label} 477 </span> 478 <input 479 className={cn( 480 "flex-1 w-0 bg-transparent border-b border-stone-300 dark:border-stone-600 print:border-stone-400 leading-none", 481 "font-hand text-stone-800 dark:text-stone-200", 482 "focus:outline-none focus:border-stone-500", 483 "text-2xl leading-none", 484 )} 485 name={name} 486 type="text" 487 /> 488 </label> 489 ); 490 } 491 492 function BlankLine({ name }: { name: string }) { 493 return ( 494 <input 495 className="w-full bg-transparent border-b border-stone-300 dark:border-stone-600 print:border-stone-400 font-hand text-xl text-stone-800 dark:text-stone-200 focus:outline-none focus:border-stone-500" 496 name={name} 497 type="text" 498 /> 499 ); 500 } 501 502 function SectionLabel({ children }: { children: React.ReactNode }) { 503 return ( 504 <h3 className="text-sm font-serif font-bold tracking-wide text-stone-700 dark:text-stone-300 mb-2"> 505 {children} 506 </h3> 507 ); 508 }