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:
@@ -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>
|
||||||
|
|||||||
165
web/src/data/weapon-templates.ts
Normal file
165
web/src/data/weapon-templates.ts
Normal 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.',
|
||||||
|
},
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user