feat(web): weapon templates for Add Weapon modal

Add 19 weapon templates from Appendix A (9 mundane + 10 spellcannons)
with pre-filled damage, range, and notes. Template picker in Add Weapon
modal is grouped by type (Mundane / Spellcannons). Selecting a template
auto-fills all fields; "Custom" option for manual entry. Also adds
notes field and two-column form layout to the modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bas van Rossem
2026-02-19 17:26:04 +01:00
parent c2432d6fab
commit 4de0b1cb2a
2 changed files with 268 additions and 58 deletions

View File

@@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Modal from '../ui/Modal'; import Modal from '../ui/Modal';
import { weaponTemplates } from '../../data/weapon-templates';
import type { WeaponTemplate } from '../../data/weapon-templates';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -7,90 +9,133 @@ interface Props {
onCreate: (weapon: Record<string, unknown>) => void; onCreate: (weapon: Record<string, unknown>) => void;
} }
interface FormState {
name: string;
type: string;
damage: string;
attackMod: string;
range: string;
ammoMax: string;
notes: string;
}
function defaults(): FormState {
return { name: '', type: '', damage: '', attackMod: '', range: '', ammoMax: '', notes: '' };
}
function fromTemplate(t: WeaponTemplate): FormState {
return {
name: t.name,
type: t.type,
damage: t.damage,
attackMod: '',
range: t.range,
ammoMax: '',
notes: t.notes ?? '',
};
}
export default function AddWeaponModal({ open, onClose, onCreate }: Props) { export default function AddWeaponModal({ open, onClose, onCreate }: Props) {
const [name, setName] = useState(''); const [templateIdx, setTemplateIdx] = useState<string>('');
const [damage, setDamage] = useState(''); const [form, setForm] = useState<FormState>(defaults());
const [attackMod, setAttackMod] = useState('');
const [range, setRange] = useState(''); const set = (field: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const [ammoMax, setAmmoMax] = useState(''); setForm((f) => ({ ...f, [field]: e.target.value }));
};
const handleTemplateChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const val = e.target.value;
setTemplateIdx(val);
if (val === '') {
setForm(defaults());
} else {
setForm(fromTemplate(weaponTemplates[parseInt(val)]));
}
};
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) return; if (!form.name.trim()) return;
const aMax = parseInt(ammoMax) || null; const aMax = parseInt(form.ammoMax) || null;
onCreate({ onCreate({
name: name.trim(), name: form.name.trim(),
damage: damage.trim() || null, type: form.type || null,
attack_mod: parseInt(attackMod) || null, damage: form.damage.trim() || null,
range: range.trim() || null, attack_mod: parseInt(form.attackMod) || null,
range: form.range.trim() || null,
ammo_max: aMax, ammo_max: aMax,
ammo_current: aMax, ammo_current: aMax,
status: 'ok', status: 'ok',
notes: form.notes.trim() || null,
}); });
setName(''); setForm(defaults());
setDamage(''); setTemplateIdx('');
setAttackMod('');
setRange('');
setAmmoMax('');
onClose(); onClose();
}; };
// Group templates by type for the dropdown
const mundane = weaponTemplates.map((t, i) => ({ t, i })).filter(({ t }) => t.type === 'mundane');
const spellcannons = weaponTemplates.map((t, i) => ({ t, i })).filter(({ t }) => t.type === 'spellcannon');
return ( return (
<Modal open={open} onClose={onClose} title="Add Weapon"> <Modal open={open} onClose={onClose} title="Add Weapon">
<form onSubmit={handleSubmit} className="modal-form"> <form onSubmit={handleSubmit} className="modal-form">
<label className="form-label"> <label className="form-label">
Name * Template
<input <select value={templateIdx} onChange={handleTemplateChange}>
type="text" <option value="">Custom</option>
value={name} <optgroup label="Mundane">
onChange={(e) => setName(e.target.value)} {mundane.map(({ t, i }) => (
placeholder="e.g. Ballista" <option key={t.name} value={i}>{t.name}</option>
autoFocus ))}
required </optgroup>
/> <optgroup label="Spellcannons">
{spellcannons.map(({ t, i }) => (
<option key={t.name} value={i}>{t.name}</option>
))}
</optgroup>
</select>
</label> </label>
<label className="form-label">
Name *
<input type="text" value={form.name} onChange={set('name')} placeholder="e.g. Ballista" autoFocus required />
</label>
<div className="form-row">
<label className="form-label"> <label className="form-label">
Damage Damage
<input <input type="text" value={form.damage} onChange={set('damage')} placeholder="e.g. 3d10" />
type="text"
value={damage}
onChange={(e) => setDamage(e.target.value)}
placeholder="e.g. 3d10"
/>
</label>
<label className="form-label">
Attack Modifier
<input
type="number"
value={attackMod}
onChange={(e) => setAttackMod(e.target.value)}
/>
</label> </label>
<label className="form-label"> <label className="form-label">
Range Range
<input <input type="text" value={form.range} onChange={set('range')} placeholder="e.g. 250/800" />
type="text" </label>
value={range} </div>
onChange={(e) => setRange(e.target.value)}
placeholder="e.g. 120/480" <div className="form-row">
/> <label className="form-label">
Attack Modifier
<input type="number" value={form.attackMod} onChange={set('attackMod')} />
</label> </label>
<label className="form-label"> <label className="form-label">
Ammo Max (leave empty for unlimited) Ammo Max
<input <input type="number" value={form.ammoMax} onChange={set('ammoMax')} min="0" placeholder="∞" />
type="number"
value={ammoMax}
onChange={(e) => setAmmoMax(e.target.value)}
min="0"
/>
</label> </label>
</div>
<label className="form-label">
Notes
<input type="text" value={form.notes} onChange={set('notes')} placeholder="e.g. Dex or Int." />
</label>
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}> <button type="button" className="btn-secondary" onClick={onClose}>
Cancel Cancel
</button> </button>
<button type="submit" className="btn-primary" disabled={!name.trim()}> <button type="submit" className="btn-primary" disabled={!form.name.trim()}>
Add Weapon Add Weapon
</button> </button>
</div> </div>

View File

@@ -0,0 +1,165 @@
export interface WeaponTemplate {
name: string;
type: 'mundane' | 'spellcannon';
damage: string;
attack_mod: number | null;
range: string;
notes: string | null;
}
export const weaponTemplates: WeaponTemplate[] = [
// Mundane weapons
{
name: 'Scorpion',
type: 'mundane',
damage: '1d6',
attack_mod: null,
range: '200/500',
notes: 'Dex. Free reload.',
},
{
name: 'Ballista',
type: 'mundane',
damage: '1d10',
attack_mod: null,
range: '250/800',
notes: 'Dex.',
},
{
name: 'Falconet (Cannon)',
type: 'mundane',
damage: '2d6',
attack_mod: null,
range: '500/1200',
notes: 'Dex or Int.',
},
{
name: 'Carronade (Cannon)',
type: 'mundane',
damage: '3d6',
attack_mod: null,
range: '300/800',
notes: 'Dex or Int.',
},
{
name: 'Saker (Cannon)',
type: 'mundane',
damage: '2d10',
attack_mod: null,
range: '750/2000',
notes: 'Dex or Int.',
},
{
name: 'Bombard',
type: 'mundane',
damage: '4d8',
attack_mod: null,
range: '750/2000',
notes: 'Dex or Int. Special mount, limited arc, slow reload.',
},
{
name: 'Minigun',
type: 'mundane',
damage: '5d4',
attack_mod: null,
range: '150/400',
notes: 'Dex. +2d4 vs subsystems. Half dmg for advantage.',
},
{
name: 'Javelin Launcher',
type: 'mundane',
damage: '2d8',
attack_mod: null,
range: '150',
notes: 'Dex. Human-scale damage. Subsystems only.',
},
{
name: 'Harpoon Launcher',
type: 'mundane',
damage: '1d6',
attack_mod: null,
range: '150',
notes: 'Dex. Attaches 150ft chain on hit.',
},
// Spellcannons
{
name: 'Magic Missile Cannon',
type: 'spellcannon',
damage: '3×(1d4+1) force',
attack_mod: null,
range: '1000',
notes: 'Auto-hit. No reload. 10 charges/day.',
},
{
name: 'Fireball Cannon',
type: 'spellcannon',
damage: '8d6 fire',
attack_mod: null,
range: '750',
notes: 'AoE 150ft radius. 5 charges/day.',
},
{
name: 'Acid Arrow Cannon',
type: 'spellcannon',
damage: '6d4 acid',
attack_mod: null,
range: '750',
notes: '7 charges/day.',
},
{
name: 'Web Cannon',
type: 'spellcannon',
damage: 'n/a',
attack_mod: null,
range: '400',
notes: '5 charges/day.',
},
{
name: 'Lightning Bolt Cannon',
type: 'spellcannon',
damage: '8d6 lightning',
attack_mod: null,
range: '750',
notes: 'AoE 500ft×50ft line. 5 charges/day.',
},
{
name: 'Disintegrate Cannon',
type: 'spellcannon',
damage: '10d6+40 force',
attack_mod: null,
range: '400',
notes: '3 charges/day.',
},
{
name: 'Cone of Cold Cannon',
type: 'spellcannon',
damage: '8d8 cold',
attack_mod: null,
range: '600',
notes: '250ft cone. 4 charges/day.',
},
{
name: 'Spiritual Weapon Cannon',
type: 'spellcannon',
damage: '1d8+4',
attack_mod: null,
range: '150',
notes: 'Lasts 1 min/charge. 3 charges/day.',
},
{
name: 'Guiding Bolt Cannon',
type: 'spellcannon',
damage: '4d6 radiant',
attack_mod: null,
range: '1000',
notes: 'Advantage to next attack. 6 charges/day.',
},
{
name: 'Flame Strike Cannon',
type: 'spellcannon',
damage: '4d6 fire + 4d6 radiant',
attack_mod: null,
range: '750',
notes: 'AoE 50ft radius. 3 charges/day.',
},
];