version 0.0.1
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
:root {
|
||||
--bg: #12121a;
|
||||
--surface: #1c1c28;
|
||||
--surface-2: #252532;
|
||||
--border: #3a3a4d;
|
||||
--text: #e8e8ef;
|
||||
--muted: #9a9ab0;
|
||||
--accent: #6c9cff;
|
||||
--accent-dim: #4a6fb8;
|
||||
--success: #5ecf8a;
|
||||
--warn: #e8c547;
|
||||
--danger: #e86c6c;
|
||||
--radius: 10px;
|
||||
--font: system-ui, 'Segoe UI', Roboto, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn:hover {
|
||||
border-color: var(--accent-dim);
|
||||
background: var(--surface);
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #0b1020;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.btn.danger {
|
||||
border-color: #8a3d3d;
|
||||
background: #3a2020;
|
||||
color: #ffc9c9;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.field label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.big-number {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#16213e" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
import { atBasis, paBasis } from '$lib/engine/derived';
|
||||
import { CURRENT_SCHEMA_VERSION } from '$lib/schema/version';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
|
||||
function defaultAttributes(): Character['eigenschaften'] {
|
||||
return {
|
||||
MU: { startwert: 11, mod: 0 },
|
||||
KL: { startwert: 11, mod: 0 },
|
||||
IN: { startwert: 11, mod: 0 },
|
||||
CH: { startwert: 11, mod: 0 },
|
||||
FF: { startwert: 11, mod: 0 },
|
||||
GE: { startwert: 11, mod: 0 },
|
||||
KO: { startwert: 11, mod: 0 },
|
||||
KK: { startwert: 11, mod: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
export function newCharacter(partial?: { name?: string; id?: string }): Character {
|
||||
const name = partial?.name?.trim() || 'Neuer Held';
|
||||
const instanceId = crypto.randomUUID();
|
||||
|
||||
const ch: Character = {
|
||||
id: partial?.id ?? crypto.randomUUID(),
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
meta: {
|
||||
name,
|
||||
rasse: 'Mensch',
|
||||
kultur: '',
|
||||
profession: ''
|
||||
},
|
||||
eigenschaften: defaultAttributes(),
|
||||
energien: {
|
||||
lep: { mod: 0 },
|
||||
aup: { mod: 0 },
|
||||
mr: { mod: 0 }
|
||||
},
|
||||
vorteile: [],
|
||||
nachteile: [],
|
||||
sonderfertigkeiten: [],
|
||||
talente: [],
|
||||
kampftalente: [],
|
||||
inventar: [],
|
||||
waffen: {
|
||||
fernkampf: [
|
||||
{
|
||||
instanceId,
|
||||
weaponDefId: 'shortbow'
|
||||
}
|
||||
],
|
||||
nahkampf: []
|
||||
}
|
||||
};
|
||||
|
||||
const ab = atBasis(ch);
|
||||
const pb = paBasis(ch);
|
||||
ch.kampftalente = [
|
||||
{ id: 'bogen', taw: 0, at: ab, pa: pb },
|
||||
{ id: 'dolche', taw: 0, at: ab, pa: pb }
|
||||
];
|
||||
|
||||
return ch;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
let mode: '1w20' | '3w20' = '1w20';
|
||||
let last: number[] = [];
|
||||
|
||||
function roll1() {
|
||||
last = [1 + Math.floor(Math.random() * 20)];
|
||||
}
|
||||
|
||||
function roll3() {
|
||||
last = [1, 2, 3].map(() => 1 + Math.floor(Math.random() * 20));
|
||||
}
|
||||
|
||||
function roll() {
|
||||
if (mode === '1w20') roll1();
|
||||
else roll3();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
<select bind:value={mode}>
|
||||
<option value="1w20">1W20</option>
|
||||
<option value="3w20">3W20</option>
|
||||
</select>
|
||||
<button type="button" class="btn primary" on:click={roll}>Würfeln</button>
|
||||
</div>
|
||||
{#if last.length}
|
||||
<p class="big-number">{last.join(' · ')}</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { AttributeKey } from '$lib/rules/attributes';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { RACE_ASP_MOD, RACE_LEP_MOD } from '$lib/rules/races';
|
||||
|
||||
export function effectiveAttr(char: Character, key: AttributeKey): number {
|
||||
const a = char.eigenschaften[key];
|
||||
return a.startwert + a.mod;
|
||||
}
|
||||
|
||||
/** DSA 4.1: AT-Basis = (MU + GE + KK) / 5, kaufmännisch gerundet */
|
||||
export function atBasis(char: Character): number {
|
||||
const v = (effectiveAttr(char, 'MU') + effectiveAttr(char, 'GE') + effectiveAttr(char, 'KK')) / 5;
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
/** PA-Basis = (IN + GE + KK) / 5 */
|
||||
export function paBasis(char: Character): number {
|
||||
const v = (effectiveAttr(char, 'IN') + effectiveAttr(char, 'GE') + effectiveAttr(char, 'KK')) / 5;
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
/** FK-Basis = (IN + FF + KK) / 4 */
|
||||
export function fkBasis(char: Character): number {
|
||||
const v = (effectiveAttr(char, 'IN') + effectiveAttr(char, 'FF') + effectiveAttr(char, 'KK')) / 4;
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
/** INI-Basis = (MU + MU + IN + GE) / 4 */
|
||||
export function iniBasis(char: Character): number {
|
||||
const v =
|
||||
(effectiveAttr(char, 'MU') +
|
||||
effectiveAttr(char, 'MU') +
|
||||
effectiveAttr(char, 'IN') +
|
||||
effectiveAttr(char, 'GE')) /
|
||||
4;
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
/** MR-Basis = (MU + KL + KO) / 5 + MR-Mod aus Energien */
|
||||
export function mrBasis(char: Character): number {
|
||||
const v =
|
||||
(effectiveAttr(char, 'MU') + effectiveAttr(char, 'KL') + effectiveAttr(char, 'KO')) / 5 +
|
||||
char.energien.mr.mod;
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
/** LeP-Maximum: (2*KO + KK) / 2 abrunden + Rassenbonus + LeP-Mod */
|
||||
export function lepMax(char: Character): number {
|
||||
const ko = effectiveAttr(char, 'KO');
|
||||
const kk = effectiveAttr(char, 'KK');
|
||||
const base = Math.floor((2 * ko + kk) / 2);
|
||||
const race = RACE_LEP_MOD[char.meta.rasse] ?? 0;
|
||||
return base + race + char.energien.lep.mod;
|
||||
}
|
||||
|
||||
export function aspMax(char: Character): number {
|
||||
if (!char.energien.asp) return 0;
|
||||
const race = RACE_ASP_MOD[char.meta.rasse] ?? 0;
|
||||
const base = effectiveAttr(char, 'IN') + effectiveAttr(char, 'IN') + effectiveAttr(char, 'CH');
|
||||
return Math.max(0, base + race + char.energien.asp.mod);
|
||||
}
|
||||
|
||||
export function aupMax(char: Character): number {
|
||||
const base = effectiveAttr(char, 'MU') + effectiveAttr(char, 'MU') + effectiveAttr(char, 'IN');
|
||||
return Math.max(0, base + char.energien.aup.mod);
|
||||
}
|
||||
|
||||
export type DerivedSheet = {
|
||||
atBasis: number;
|
||||
paBasis: number;
|
||||
fkBasis: number;
|
||||
iniBasis: number;
|
||||
mrBasis: number;
|
||||
lepMax: number;
|
||||
aspMax: number;
|
||||
aupMax: number;
|
||||
};
|
||||
|
||||
export function computeDerived(char: Character): DerivedSheet {
|
||||
return {
|
||||
atBasis: atBasis(char),
|
||||
paBasis: paBasis(char),
|
||||
fkBasis: fkBasis(char),
|
||||
iniBasis: iniBasis(char),
|
||||
mrBasis: mrBasis(char),
|
||||
lepMax: lepMax(char),
|
||||
aspMax: aspMax(char),
|
||||
aupMax: aupMax(char)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { atBasis, paBasis } from '$lib/engine/derived';
|
||||
|
||||
export type MeleeSelections = {
|
||||
/** Manöver / Situation als Liste von Modifikator-Werten (Erschwernis positiv) */
|
||||
situationModifiers: number[];
|
||||
/** Zusätzliche AT-Erschwernis (z.B. Wuchtschlag in Stufen) */
|
||||
atExtra: number;
|
||||
/** Zusätzliche PA-Erschwernis */
|
||||
paExtra: number;
|
||||
/** Ansage: AT-Teil wird auf die PA verteilt (vereinfacht: nur Anzeige, keine Auto-Berechnung) */
|
||||
ansageAtAnteil: number;
|
||||
};
|
||||
|
||||
export type MeleeResult = {
|
||||
atBasis: number;
|
||||
paBasis: number;
|
||||
talentId: string;
|
||||
taw: number;
|
||||
at: number;
|
||||
pa: number;
|
||||
atTarget: number;
|
||||
paTarget: number;
|
||||
modifierSum: number;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Nahkampf-Zielwerte: AT/PA aus Bogen + TaW + Waffen-Modifikatoren + gewählte Erschwernisse.
|
||||
* Manöver-Details (Wuchtschlag/Finte) bleiben bewusst generisch – Werte am Tisch eintragen.
|
||||
*/
|
||||
export function computeMeleeTargets(
|
||||
char: Character,
|
||||
talentId: string,
|
||||
weaponAtMod: number,
|
||||
weaponPaMod: number,
|
||||
selections: MeleeSelections
|
||||
): MeleeResult {
|
||||
const kt = char.kampftalente.find((k) => k.id === talentId);
|
||||
const taw = kt?.taw ?? 0;
|
||||
const at = (kt?.at ?? atBasis(char) + taw) + weaponAtMod;
|
||||
const pa = (kt?.pa ?? paBasis(char) + taw) + weaponPaMod;
|
||||
|
||||
const modifierSum =
|
||||
selections.situationModifiers.reduce((a, b) => a + b, 0) +
|
||||
selections.atExtra +
|
||||
selections.paExtra;
|
||||
|
||||
const atTarget = at - modifierSum - selections.ansageAtAnteil;
|
||||
const paTarget = pa - modifierSum + selections.ansageAtAnteil;
|
||||
|
||||
const notes: string[] = [];
|
||||
if (selections.ansageAtAnteil > 0) {
|
||||
notes.push(
|
||||
`Ansage: ${selections.ansageAtAnteil} AT-Erschwernis gelten zusätzlich als PA-Erschwernis (hier vereinfacht symmetrisch verteilt).`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
atBasis: atBasis(char),
|
||||
paBasis: paBasis(char),
|
||||
talentId,
|
||||
taw,
|
||||
at,
|
||||
pa,
|
||||
atTarget,
|
||||
paTarget,
|
||||
modifierSum,
|
||||
notes
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { AttributeKey } from '$lib/rules/attributes';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { effectiveAttr } from '$lib/engine/derived';
|
||||
|
||||
/** Einfache 1W20-Eigenschaftsprobe: Zielwert = effektiver Attributswert */
|
||||
export function attributeProbeTarget(char: Character, key: AttributeKey): number {
|
||||
return effectiveAttr(char, key);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { effectiveAttr, fkBasis } from '$lib/engine/derived';
|
||||
import type { RangedWeaponId } from '$lib/rules/weapons-ranged';
|
||||
import { getRangedWeapon } from '$lib/rules/weapons-ranged';
|
||||
import type { Expertise } from '$lib/rules/expertise';
|
||||
import {
|
||||
HIT_ZONES,
|
||||
OTHER_MODIFIERS,
|
||||
RANGES,
|
||||
RELOAD_STATES,
|
||||
TARGET_MOVEMENT,
|
||||
TARGET_SIZES,
|
||||
VISIBILITY,
|
||||
isStringModifier,
|
||||
resolveNumericModifier,
|
||||
type ExtraModifierId,
|
||||
type HitZoneId,
|
||||
type MovementId,
|
||||
type RangeId,
|
||||
type ReloadStateId,
|
||||
type TargetSizeId,
|
||||
type VisibilityId
|
||||
} from '$lib/rules/modifiers-ranged';
|
||||
|
||||
export type { Expertise } from '$lib/rules/expertise';
|
||||
|
||||
export type RangedSelections = {
|
||||
range: RangeId;
|
||||
targetSize: TargetSizeId;
|
||||
visibility: VisibilityId;
|
||||
movement: MovementId;
|
||||
hitZone?: HitZoneId;
|
||||
reloadState?: ReloadStateId;
|
||||
extras: ExtraModifierId[];
|
||||
aimTurns: number;
|
||||
targetBuild: 'biped' | 'quadruped';
|
||||
};
|
||||
|
||||
export type ModifierLine = { id: string; label: string; value: number; note?: string };
|
||||
|
||||
export type RangedResult = {
|
||||
fkBase: number;
|
||||
weaponTaW: number;
|
||||
effectiveTaW: number;
|
||||
baseTarget: number;
|
||||
modifiers: ModifierLine[];
|
||||
totalModifier: number;
|
||||
finalTarget: number;
|
||||
damageSummary: string;
|
||||
strengthNote?: string;
|
||||
reloadNote?: string;
|
||||
};
|
||||
|
||||
function rowById<T extends { id: string }>(rows: T[], id: string): T | undefined {
|
||||
return rows.find((r) => r.id === id);
|
||||
}
|
||||
|
||||
function parseBonusDamage(bonusDamage: string, rangeIndex: number): number {
|
||||
const parts = bonusDamage.split('/').map((p) => parseInt(p.trim(), 10));
|
||||
const v = parts[rangeIndex];
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
function rangeIndex(id: RangeId): number {
|
||||
const order: RangeId[] = ['very_near', 'near', 'medium', 'far', 'very_far'];
|
||||
return order.indexOf(id);
|
||||
}
|
||||
|
||||
function effectiveTaWForReload(
|
||||
rawTaW: number,
|
||||
reloadId: ReloadStateId | undefined,
|
||||
expertise: Expertise
|
||||
): { effectiveTaW: number; reloadNote?: string } {
|
||||
if (!reloadId) return { effectiveTaW: rawTaW };
|
||||
const row = rowById(RELOAD_STATES, reloadId);
|
||||
if (!row) return { effectiveTaW: rawTaW };
|
||||
|
||||
if (reloadId === 'aimed_shot' || reloadId === 'announced') {
|
||||
if (expertise === 'master' && typeof row.modifier_master === 'number') {
|
||||
return {
|
||||
effectiveTaW: rawTaW,
|
||||
reloadNote: `${row.name}: voller TaW, zusätzliche Erschwernis +${row.modifier_master}`
|
||||
};
|
||||
}
|
||||
if (expertise === 'expert' && row.modifier_expert !== undefined) {
|
||||
const s = String(row.modifier_expert);
|
||||
const m = s.match(/^\/2\s*([+-]\d+)?/);
|
||||
if (m) {
|
||||
const extra = m[1] ? parseInt(m[1], 10) : 0;
|
||||
return {
|
||||
effectiveTaW: Math.floor(rawTaW / 2) + extra,
|
||||
reloadNote: `${row.name}: TaW/2${extra ? ` ${extra >= 0 ? '+' : ''}${extra}` : ''}`
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
effectiveTaW: Math.floor(rawTaW / 2),
|
||||
reloadNote: `${row.name}: TaW/2 (abgerundet)`
|
||||
};
|
||||
}
|
||||
|
||||
return { effectiveTaW: rawTaW };
|
||||
}
|
||||
|
||||
export function computeRangedTarget(
|
||||
char: Character,
|
||||
weaponId: RangedWeaponId,
|
||||
selections: RangedSelections,
|
||||
expertise: Expertise
|
||||
): RangedResult {
|
||||
const weapon = getRangedWeapon(weaponId);
|
||||
if (!weapon) {
|
||||
throw new Error(`Unknown weapon: ${weaponId}`);
|
||||
}
|
||||
|
||||
const fkBase = fkBasis(char);
|
||||
const kt = char.kampftalente.find((k) => k.id === weapon.skill);
|
||||
const rawTaW = kt?.taw ?? 0;
|
||||
|
||||
const { effectiveTaW, reloadNote } = effectiveTaWForReload(
|
||||
rawTaW,
|
||||
selections.reloadState,
|
||||
expertise
|
||||
);
|
||||
|
||||
const baseTarget = fkBase + effectiveTaW;
|
||||
|
||||
const modifiers: ModifierLine[] = [];
|
||||
|
||||
const push = (id: string, label: string, value: number, note?: string) => {
|
||||
if (value !== 0 || note) modifiers.push({ id, label, value, note });
|
||||
};
|
||||
|
||||
let strengthNote: string | undefined;
|
||||
if (weapon.strengthRequirement !== undefined && weapon.strengthModifier !== undefined) {
|
||||
const kk = effectiveAttr(char, 'KK');
|
||||
if (kk < weapon.strengthRequirement) {
|
||||
const pen = -weapon.strengthModifier;
|
||||
push('strength', `KK zu niedrig (<${weapon.strengthRequirement})`, pen);
|
||||
strengthNote = `Erschwernis +${pen} (Regel: Bogen zu stark)`;
|
||||
}
|
||||
}
|
||||
|
||||
const rangeRow = rowById(RANGES, selections.range);
|
||||
const sizeRow = rowById(TARGET_SIZES, selections.targetSize);
|
||||
const visRow = rowById(VISIBILITY, selections.visibility);
|
||||
const movRow = rowById(TARGET_MOVEMENT, selections.movement);
|
||||
|
||||
for (const [row, label] of [
|
||||
[rangeRow, 'Entfernung'],
|
||||
[sizeRow, 'Zielgröße'],
|
||||
[visRow, 'Sicht'],
|
||||
[movRow, 'Bewegung']
|
||||
] as const) {
|
||||
const v = resolveNumericModifier(row, 'none');
|
||||
if (row && v !== null) push(row.id, `${label}: ${row.name}`, v);
|
||||
}
|
||||
|
||||
if (selections.hitZone) {
|
||||
const hz = rowById(HIT_ZONES, selections.hitZone);
|
||||
if (hz && hz.build === selections.targetBuild) {
|
||||
const v = resolveNumericModifier(hz, expertise);
|
||||
if (v !== null) push(hz.id, `Trefferzone: ${hz.name}`, v);
|
||||
}
|
||||
}
|
||||
|
||||
if (selections.reloadState) {
|
||||
const rRow = rowById(RELOAD_STATES, selections.reloadState);
|
||||
if (rRow) {
|
||||
const isAimedOrAnnounced = selections.reloadState === 'aimed_shot' || selections.reloadState === 'announced';
|
||||
if (isAimedOrAnnounced && expertise === 'master' && typeof rRow.modifier_master === 'number') {
|
||||
push(rRow.id, `Schussart: ${rRow.name}`, rRow.modifier_master);
|
||||
} else if (!isAimedOrAnnounced || !isStringModifier(rRow, expertise)) {
|
||||
const v = resolveNumericModifier(rRow, expertise);
|
||||
if (v !== null && !(isAimedOrAnnounced && typeof rRow.modifier === 'string')) {
|
||||
push(rRow.id, `Schussart: ${rRow.name}`, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ex of selections.extras) {
|
||||
const row = rowById(OTHER_MODIFIERS, ex);
|
||||
if (!row) continue;
|
||||
if (row.skill === 'archery' && weapon.skill !== 'bogen') continue;
|
||||
const v = resolveNumericModifier(row, expertise);
|
||||
if (v !== null) push(row.id, row.name, v);
|
||||
}
|
||||
|
||||
if (selections.aimTurns > 0) {
|
||||
const aimRow = rowById(OTHER_MODIFIERS, 'aim');
|
||||
const per = resolveNumericModifier(aimRow ?? { id: 'aim', name: 'Zielen', modifier: -0.5 }, expertise);
|
||||
const v = (typeof per === 'number' ? per : -0.5) * selections.aimTurns;
|
||||
push('aim', `Zielen (${selections.aimTurns} KR)`, v);
|
||||
}
|
||||
|
||||
const totalModifier = modifiers.reduce((s, m) => s + m.value, 0);
|
||||
const finalTarget = baseTarget - totalModifier;
|
||||
|
||||
const ri = rangeIndex(selections.range);
|
||||
const bonus = parseBonusDamage(weapon.bonusDamage, ri);
|
||||
const damageSummary =
|
||||
bonus === 0 ? `${weapon.damage}` : `${weapon.damage} + ${bonus} (Entfernungs-Bonus)`;
|
||||
|
||||
return {
|
||||
fkBase,
|
||||
weaponTaW: rawTaW,
|
||||
effectiveTaW,
|
||||
baseTarget,
|
||||
modifiers,
|
||||
totalModifier,
|
||||
finalTarget,
|
||||
damageSummary,
|
||||
strengthNote,
|
||||
reloadNote
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import type { AttributeKey } from '$lib/rules/attributes';
|
||||
import { effectiveAttr } from '$lib/engine/derived';
|
||||
|
||||
export type SpellCastContext = {
|
||||
zfpTarget?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* DSA 4.1 Zauberprobe: 3W20 gegen (ZfW + jeweilige Eigenschaft).
|
||||
* ZfP*-Verteilung erfolgt am Tisch.
|
||||
*/
|
||||
export function spellProbeTargets(
|
||||
char: Character,
|
||||
probeAttrs: readonly [AttributeKey, AttributeKey, AttributeKey],
|
||||
zfw: number
|
||||
): { targets: [number, number, number]; attrs: AttributeKey[] } {
|
||||
const attrs = [...probeAttrs] as AttributeKey[];
|
||||
const targets: [number, number, number] = [
|
||||
effectiveAttr(char, probeAttrs[0]) + zfw,
|
||||
effectiveAttr(char, probeAttrs[1]) + zfw,
|
||||
effectiveAttr(char, probeAttrs[2]) + zfw
|
||||
];
|
||||
return { targets, attrs };
|
||||
}
|
||||
|
||||
export function listSpells(char: Character) {
|
||||
return char.zauber ?? [];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { getTalent } from '$lib/rules/talents';
|
||||
import { effectiveAttr } from '$lib/engine/derived';
|
||||
import type { AttributeKey } from '$lib/rules/attributes';
|
||||
|
||||
export type TalentProbeTargets = {
|
||||
talentId: string;
|
||||
taw: number;
|
||||
/** Zielwerte für die drei W20 (in Reihenfolge der Talentprobe) */
|
||||
targets: [number, number, number];
|
||||
/** TaW + jeweilige Eigenschaft (Hilfsanzeige) */
|
||||
details: { attr: AttributeKey; attrValue: number; sum: number }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 3W20-Talentprobe: jede einzelne Probe gegen (TaW + effektive Eigenschaft).
|
||||
* TaP*-Berechnung erfolgt am Tisch; hier nur die Zielzahlen.
|
||||
*/
|
||||
export function talentProbeTargets(char: Character, talentId: string, taw: number): TalentProbeTargets {
|
||||
const def = getTalent(talentId);
|
||||
const attrs: AttributeKey[] = def?.probe
|
||||
? ([...def.probe] as AttributeKey[])
|
||||
: (['MU', 'IN', 'GE'] as const);
|
||||
const details = attrs.map((attr) => {
|
||||
const attrValue = effectiveAttr(char, attr);
|
||||
return { attr, attrValue, sum: taw + attrValue };
|
||||
});
|
||||
const targets: [number, number, number] = [
|
||||
details[0]?.sum ?? taw,
|
||||
details[1]?.sum ?? taw,
|
||||
details[2]?.sum ?? taw
|
||||
];
|
||||
return { talentId, taw, targets, details };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Reserviert für zukünftigen Import aus Helden-Software (.helden / XML).
|
||||
* Schema der Charakterdaten ist in `schema/character.ts` darauf vorbereitet.
|
||||
*/
|
||||
export function parseHeldenXml(xml: string): never {
|
||||
void xml;
|
||||
throw new Error('Helden-Software-Import ist noch nicht implementiert.');
|
||||
}
|
||||
|
||||
export function isHeldenImportSupported(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const ATTRIBUTE_KEYS = ['MU', 'KL', 'IN', 'CH', 'FF', 'GE', 'KO', 'KK'] as const;
|
||||
|
||||
export type AttributeKey = (typeof ATTRIBUTE_KEYS)[number];
|
||||
|
||||
export const ATTRIBUTE_LABELS: Record<AttributeKey, string> = {
|
||||
MU: 'Mut',
|
||||
KL: 'Klugheit',
|
||||
IN: 'Intuition',
|
||||
CH: 'Charisma',
|
||||
FF: 'Fingerfertigkeit',
|
||||
GE: 'Gewandtheit',
|
||||
KO: 'Konstitution',
|
||||
KK: 'Körperkraft'
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Scharfschütze / Meisterschütze (oder vergleichbare SF) für Fernkampf-Modifikatoren */
|
||||
export type Expertise = 'none' | 'expert' | 'master';
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { Expertise } from '$lib/rules/expertise';
|
||||
|
||||
export type RangeId = 'very_near' | 'near' | 'medium' | 'far' | 'very_far';
|
||||
export type TargetSizeId =
|
||||
| 'very_large'
|
||||
| 'large'
|
||||
| 'medium'
|
||||
| 'small'
|
||||
| 'very_small'
|
||||
| 'tiny';
|
||||
export type VisibilityId =
|
||||
| 'clear'
|
||||
| 'haze'
|
||||
| 'fog'
|
||||
| 'dusk'
|
||||
| 'moonlight'
|
||||
| 'starlight'
|
||||
| 'darkness'
|
||||
| 'invisible';
|
||||
export type MovementId = 'stationary' | 'standing' | 'slow' | 'fast' | 'very_fast' | 'battle';
|
||||
export type HitZoneId =
|
||||
| 'head'
|
||||
| 'torso'
|
||||
| 'arms'
|
||||
| 'stomach'
|
||||
| 'legs'
|
||||
| 'hand_foot'
|
||||
| 'eye_heart'
|
||||
| 'qrump'
|
||||
| 'qleg'
|
||||
| 'weakspot'
|
||||
| 'qhead'
|
||||
| 'qtail'
|
||||
| 'qsensory_organs';
|
||||
export type ExtraModifierId =
|
||||
| 'steep_shot_down'
|
||||
| 'steep_shot_up'
|
||||
| 'gusty_crosswind'
|
||||
| 'strong_gusty_crosswind'
|
||||
| 'fast_shot'
|
||||
| 'aim';
|
||||
export type ReloadStateId =
|
||||
| 'arrow_nocked'
|
||||
| 'bow_drawn'
|
||||
| 'regular_shot'
|
||||
| 'fast_shot_bow'
|
||||
| 'aimed_shot'
|
||||
| 'announced'
|
||||
| 'fast_reload_bow';
|
||||
|
||||
export type ModifierRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
modifier: number | string;
|
||||
modifier_expert?: number | string;
|
||||
modifier_master?: number | string;
|
||||
description?: string;
|
||||
skill?: string;
|
||||
special?: string;
|
||||
build?: 'biped' | 'quadruped';
|
||||
random?: string;
|
||||
};
|
||||
|
||||
export const RANGES: ModifierRow[] = [
|
||||
{ id: 'very_near', name: 'sehr nah', modifier: -2 },
|
||||
{ id: 'near', name: 'nah', modifier: 0 },
|
||||
{ id: 'medium', name: 'mittel', modifier: 4 },
|
||||
{ id: 'far', name: 'weit', modifier: 8 },
|
||||
{ id: 'very_far', name: 'extrem weit', modifier: 12 }
|
||||
];
|
||||
|
||||
export const TARGET_SIZES: ModifierRow[] = [
|
||||
{
|
||||
id: 'very_large',
|
||||
name: 'sehr groß',
|
||||
description: 'Scheunentor, Drache, Elefant, Riese',
|
||||
modifier: -2
|
||||
},
|
||||
{
|
||||
id: 'large',
|
||||
name: 'groß',
|
||||
description: 'Pferd, Steppenrind, Oger, Troll',
|
||||
modifier: 0
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
name: 'mittel',
|
||||
description: 'Mensch, Elf, Ork, Goblin, Zwerg',
|
||||
modifier: 2
|
||||
},
|
||||
{
|
||||
id: 'small',
|
||||
name: 'klein',
|
||||
description: 'Wolf, Reh, Schaf',
|
||||
modifier: 4
|
||||
},
|
||||
{
|
||||
id: 'very_small',
|
||||
name: 'sehr klein',
|
||||
description: 'Schlange, Fasan, Katze, Rabe',
|
||||
modifier: 6
|
||||
},
|
||||
{
|
||||
id: 'tiny',
|
||||
name: 'winzig',
|
||||
description: 'Münze, Drachenauge, Maus, Kröte',
|
||||
modifier: 8
|
||||
}
|
||||
];
|
||||
|
||||
export const VISIBILITY: ModifierRow[] = [
|
||||
{ id: 'clear', name: 'klar', modifier: 0 },
|
||||
{ id: 'haze', name: 'Dunst', modifier: 2 },
|
||||
{ id: 'fog', name: 'Nebel', modifier: 4 },
|
||||
{ id: 'dusk', name: 'Dämmerung', modifier: 2 },
|
||||
{ id: 'moonlight', name: 'Mondlicht', modifier: 4 },
|
||||
{ id: 'starlight', name: 'Sternenlicht', modifier: 6 },
|
||||
{ id: 'darkness', name: 'Finsternis', modifier: 8 },
|
||||
{ id: 'invisible', name: 'Unsichtbares Ziel', modifier: 8 }
|
||||
];
|
||||
|
||||
export const TARGET_MOVEMENT: ModifierRow[] = [
|
||||
{ id: 'stationary', name: 'unbewegliches / fest montiertes Ziel', modifier: -4 },
|
||||
{ id: 'standing', name: 'stillstehendes Ziel', modifier: -2 },
|
||||
{ id: 'slow', name: 'leichte Bewegung des Ziels', modifier: 0 },
|
||||
{ id: 'fast', name: 'schnelle Bewegung des Ziels', modifier: 2 },
|
||||
{
|
||||
id: 'very_fast',
|
||||
name: 'sehr schnelle Bewegung des Ziels / Ausweichbewegungen',
|
||||
modifier: 4
|
||||
},
|
||||
{ id: 'battle', name: 'Kampfgetümmel', modifier: 2, special: 'true' }
|
||||
];
|
||||
|
||||
export const HIT_ZONES: ModifierRow[] = [
|
||||
{
|
||||
id: 'head',
|
||||
name: 'Kopf',
|
||||
modifier: 10,
|
||||
modifier_expert: 7,
|
||||
modifier_master: 5,
|
||||
build: 'biped',
|
||||
random: '19-20'
|
||||
},
|
||||
{
|
||||
id: 'torso',
|
||||
name: 'Torso',
|
||||
modifier: 6,
|
||||
modifier_expert: 4,
|
||||
modifier_master: 3,
|
||||
build: 'biped',
|
||||
random: '15-18'
|
||||
},
|
||||
{
|
||||
id: 'arms',
|
||||
name: 'Arme',
|
||||
modifier: 10,
|
||||
modifier_expert: 7,
|
||||
modifier_master: 5,
|
||||
build: 'biped',
|
||||
random: '9-14'
|
||||
},
|
||||
{
|
||||
id: 'stomach',
|
||||
name: 'Bauch',
|
||||
modifier: 6,
|
||||
modifier_expert: 4,
|
||||
modifier_master: 3,
|
||||
build: 'biped',
|
||||
random: '7-8'
|
||||
},
|
||||
{
|
||||
id: 'legs',
|
||||
name: 'Beine',
|
||||
modifier: 8,
|
||||
modifier_expert: 5,
|
||||
modifier_master: 4,
|
||||
build: 'biped',
|
||||
random: '1-6'
|
||||
},
|
||||
{
|
||||
id: 'hand_foot',
|
||||
name: 'Hand/Fuß',
|
||||
modifier: 16,
|
||||
modifier_expert: 11,
|
||||
modifier_master: 8,
|
||||
build: 'biped'
|
||||
},
|
||||
{
|
||||
id: 'eye_heart',
|
||||
name: 'Auge/Herz',
|
||||
modifier: 20,
|
||||
modifier_expert: 13,
|
||||
modifier_master: 10,
|
||||
build: 'biped'
|
||||
},
|
||||
{
|
||||
id: 'qrump',
|
||||
name: 'Rumpf',
|
||||
modifier: 4,
|
||||
modifier_expert: 3,
|
||||
modifier_master: 2,
|
||||
build: 'quadruped',
|
||||
random: '1-8'
|
||||
},
|
||||
{
|
||||
id: 'qleg',
|
||||
name: 'Bein',
|
||||
modifier: 10,
|
||||
modifier_expert: 7,
|
||||
modifier_master: 5,
|
||||
build: 'quadruped',
|
||||
random: '9-16'
|
||||
},
|
||||
{
|
||||
id: 'weakspot',
|
||||
name: 'verwundbare Stelle',
|
||||
modifier: 12,
|
||||
modifier_expert: 8,
|
||||
modifier_master: 6,
|
||||
build: 'quadruped',
|
||||
random: '1-8'
|
||||
},
|
||||
{
|
||||
id: 'qhead',
|
||||
name: 'Kopf',
|
||||
modifier: 16,
|
||||
modifier_expert: 11,
|
||||
modifier_master: 8,
|
||||
build: 'quadruped',
|
||||
random: '17-19'
|
||||
},
|
||||
{
|
||||
id: 'qtail',
|
||||
name: 'Schwanz',
|
||||
modifier: 16,
|
||||
modifier_expert: 11,
|
||||
modifier_master: 8,
|
||||
build: 'quadruped',
|
||||
random: '20'
|
||||
},
|
||||
{
|
||||
id: 'qsensory_organs',
|
||||
name: 'Sinnesorgane',
|
||||
modifier: 16,
|
||||
modifier_expert: 11,
|
||||
modifier_master: 8,
|
||||
build: 'quadruped',
|
||||
random: '20'
|
||||
}
|
||||
];
|
||||
|
||||
export const OTHER_MODIFIERS: ModifierRow[] = [
|
||||
{
|
||||
id: 'steep_shot_down',
|
||||
name: 'Steilschuss nach unten',
|
||||
modifier: 2,
|
||||
skill: 'archery'
|
||||
},
|
||||
{
|
||||
id: 'steep_shot_up',
|
||||
name: 'Steilschuss nach oben',
|
||||
modifier: 4,
|
||||
skill: 'archery'
|
||||
},
|
||||
{ id: 'gusty_crosswind', name: 'böiger Seitenwind', modifier: 4 },
|
||||
{ id: 'strong_gusty_crosswind', name: 'starker, böiger Seitenwind', modifier: 8 },
|
||||
{
|
||||
id: 'fast_shot',
|
||||
name: 'Schnellschuss',
|
||||
modifier: 2,
|
||||
modifier_expert: 1,
|
||||
modifier_master: 0
|
||||
},
|
||||
{
|
||||
id: 'aim',
|
||||
name: 'Zielen',
|
||||
modifier: -0.5,
|
||||
modifier_expert: -1,
|
||||
modifier_master: -1
|
||||
}
|
||||
];
|
||||
|
||||
export const RELOAD_STATES: ModifierRow[] = [
|
||||
{ id: 'arrow_nocked', name: 'Pfeil auf Sehne', modifier: -1 },
|
||||
{ id: 'bow_drawn', name: 'Bogen bereits gespannt', modifier: -2 },
|
||||
{ id: 'regular_shot', name: 'Normaler Schuss', modifier: 1 },
|
||||
{ id: 'fast_shot_bow', name: 'Normaler Schuss (schnell)', modifier: 0 },
|
||||
{
|
||||
id: 'aimed_shot',
|
||||
name: 'Gezielter Schuss',
|
||||
modifier: '/2',
|
||||
modifier_expert: '/2 -2',
|
||||
modifier_master: 1
|
||||
},
|
||||
{
|
||||
id: 'announced',
|
||||
name: 'Fernkampfangriff mit Ansage',
|
||||
modifier: '/2',
|
||||
modifier_expert: '/2 -2',
|
||||
modifier_master: 1
|
||||
},
|
||||
{ id: 'fast_reload_bow', name: 'Schnellladen', modifier: -1, skill: 'archery' }
|
||||
];
|
||||
|
||||
export function resolveNumericModifier(
|
||||
row: ModifierRow | undefined,
|
||||
expertise: Expertise
|
||||
): number | null {
|
||||
if (!row) return null;
|
||||
const raw =
|
||||
expertise === 'master' && row.modifier_master !== undefined
|
||||
? row.modifier_master
|
||||
: expertise === 'expert' && row.modifier_expert !== undefined
|
||||
? row.modifier_expert
|
||||
: row.modifier;
|
||||
if (typeof raw === 'number') return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isStringModifier(row: ModifierRow | undefined, expertise: Expertise): boolean {
|
||||
if (!row) return false;
|
||||
const raw =
|
||||
expertise === 'master' && row.modifier_master !== undefined
|
||||
? row.modifier_master
|
||||
: expertise === 'expert' && row.modifier_expert !== undefined
|
||||
? row.modifier_expert
|
||||
: row.modifier;
|
||||
return typeof raw === 'string';
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** LeP-Modifikator je Rasse (typische Werte DSA 4.1, vereinfacht) */
|
||||
export const RACE_LEP_MOD: Record<string, number> = {
|
||||
Mensch: 5,
|
||||
Elf: 2,
|
||||
Halbelf: 4,
|
||||
Zwerg: 8,
|
||||
Halbling: 0,
|
||||
Gnom: -4,
|
||||
Halbork: 6,
|
||||
Ork: 8,
|
||||
Goblin: -5,
|
||||
'': 0
|
||||
};
|
||||
|
||||
export const RACE_ASP_MOD: Record<string, number> = {
|
||||
Mensch: 0,
|
||||
Elf: 12,
|
||||
Halbelf: 6,
|
||||
Zwerg: 0,
|
||||
Halbling: 0,
|
||||
Gnom: 0,
|
||||
Halbork: 0,
|
||||
Ork: 0,
|
||||
Goblin: 0,
|
||||
'': 0
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { RangedSkillId } from '$lib/rules/weapons-ranged';
|
||||
|
||||
export type TalentCategory = 'allgemein' | 'kampf' | 'magie' | 'sonstig';
|
||||
|
||||
export type TalentDef = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: TalentCategory;
|
||||
probe?: readonly [string, string, string];
|
||||
/** Kampftalente mit Fernkampf-Untertyp */
|
||||
rangedSkill?: RangedSkillId;
|
||||
};
|
||||
|
||||
/** Auszug Talentliste DSA 4.1 – erweiterbar */
|
||||
export const TALENTS: TalentDef[] = [
|
||||
{ id: 'akrobatik', name: 'Akrobatik', category: 'allgemein', probe: ['GE', 'GE', 'KK'] },
|
||||
{ id: 'alchimie', name: 'Alchimie', category: 'allgemein', probe: ['MU', 'KL', 'FF'] },
|
||||
{ id: 'bogen', name: 'Bogen', category: 'kampf', probe: ['IN', 'FF', 'KK'], rangedSkill: 'bogen' },
|
||||
{
|
||||
id: 'armbrust',
|
||||
name: 'Armbrust',
|
||||
category: 'kampf',
|
||||
probe: ['IN', 'FF', 'KK'],
|
||||
rangedSkill: 'armbrust'
|
||||
},
|
||||
{
|
||||
id: 'wurfwaffe',
|
||||
name: 'Wurfwaffen',
|
||||
category: 'kampf',
|
||||
probe: ['IN', 'FF', 'KK'],
|
||||
rangedSkill: 'wurfwaffe'
|
||||
},
|
||||
{
|
||||
id: 'schleuder',
|
||||
name: 'Schleuder',
|
||||
category: 'kampf',
|
||||
probe: ['IN', 'FF', 'KK'],
|
||||
rangedSkill: 'schleuder'
|
||||
},
|
||||
{ id: 'dolche', name: 'Dolche', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'fechtwaffen', name: 'Fechtwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'hiebwaffen', name: 'Hiebwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'kettenwaffen', name: 'Kettenwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'lanzenreiterei', name: 'Lanzenreiterei', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'parierwaffen', name: 'Parierwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'raufen', name: 'Raufen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'schilde', name: 'Schilde', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'schwerter', name: 'Schwerter', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'speere', name: 'Speere', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'stangenwaffen', name: 'Stangenwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'zweihandhiebwaffen', name: 'Zweihand-Hiebwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'zweihandschwerter', name: 'Zweihand-Schwerter', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
|
||||
{ id: 'athletik', name: 'Athletik', category: 'allgemein', probe: ['GE', 'KO', 'KK'] },
|
||||
{ id: 'menschenkenntnis', name: 'Menschenkenntnis', category: 'allgemein', probe: ['MU', 'IN', 'CH'] },
|
||||
{ id: 'uberreden', name: 'Überreden', category: 'allgemein', probe: ['MU', 'IN', 'CH'] },
|
||||
{ id: 'uberzeugen', name: 'Überzeugen', category: 'allgemein', probe: ['MU', 'IN', 'CH'] },
|
||||
{ id: 'betoeren', name: 'Betören', category: 'allgemein', probe: ['CH', 'CH', 'KK'] },
|
||||
{ id: 'gassenwissen', name: 'Gassenwissen', category: 'allgemein', probe: ['KL', 'IN', 'CH'] },
|
||||
{ id: 'handel', name: 'Handel', category: 'allgemein', probe: ['KL', 'IN', 'CH'] },
|
||||
{ id: 'heimlichkeit', name: 'Heimlichkeit', category: 'allgemein', probe: ['MU', 'IN', 'GE'] },
|
||||
{ id: 'tierkunde', name: 'Tierkunde', category: 'allgemein', probe: ['MU', 'IN', 'CH'] },
|
||||
{ id: 'pflanzenkunde', name: 'Pflanzenkunde', category: 'allgemein', probe: ['KL', 'IN', 'FF'] },
|
||||
{ id: 'wildnisleben', name: 'Wildnisleben', category: 'allgemein', probe: ['MU', 'IN', 'GE'] },
|
||||
{ id: 'fischen', name: 'Fischen & Angeln', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
|
||||
{ id: 'tanzen', name: 'Tanzen', category: 'allgemein', probe: ['KL', 'CH', 'GE'] },
|
||||
{ id: 'musizieren', name: 'Musizieren', category: 'allgemein', probe: ['CH', 'FF', 'KK'] },
|
||||
{ id: 'schloesser_knacken', name: 'Schlösser knacken', category: 'allgemein', probe: ['IN', 'FF', 'KK'] },
|
||||
{ id: 'fallenstellen', name: 'Fallen stellen', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
|
||||
{ id: 'seilkunst', name: 'Seilkunst', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
|
||||
{ id: 'schneidern', name: 'Schneidern', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
|
||||
{ id: 'schmieden', name: 'Schmieden', category: 'allgemein', probe: ['FF', 'KO', 'KK'] },
|
||||
{ id: 'steinbearbeitung', name: 'Steinbearbeitung', category: 'allgemein', probe: ['FF', 'KO', 'KK'] },
|
||||
{ id: 'holzbearbeitung', name: 'Holzbearbeitung', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
|
||||
{ id: 'lederarbeiten', name: 'Lederarbeiten', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
|
||||
{ id: 'kochen', name: 'Kochen', category: 'allgemein', probe: ['KL', 'IN', 'FF'] },
|
||||
{ id: 'lesen_schreiben', name: 'Lesen & Schreiben', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
|
||||
{ id: 'sprachen', name: 'Sprachen kennen', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
|
||||
{ id: 'rechnen', name: 'Rechnen', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
|
||||
{ id: 'sagen_sagenkunde', name: 'Sagen & Sagenkunde', category: 'allgemein', probe: ['KL', 'IN', 'CH'] },
|
||||
{ id: 'staatskunst', name: 'Staatskunst', category: 'allgemein', probe: ['KL', 'IN', 'CH'] },
|
||||
{ id: 'geographie', name: 'Geographie', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
|
||||
{ id: 'geschichtswissen', name: 'Geschichtswissen', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
|
||||
{ id: 'götter_kulte', name: 'Götter & Kulte', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
|
||||
{ id: 'magiekunde', name: 'Magiekunde', category: 'magie', probe: ['KL', 'KL', 'IN'] },
|
||||
{ id: 'mechanik', name: 'Mechanik', category: 'allgemein', probe: ['KL', 'FF', 'KK'] }
|
||||
];
|
||||
|
||||
export function getTalent(id: string): TalentDef | undefined {
|
||||
return TALENTS.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
export function getTalentByRangedSkill(skill: RangedSkillId): TalentDef | undefined {
|
||||
return TALENTS.find((t) => t.rangedSkill === skill);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export type RangedWeaponId =
|
||||
| 'elven_bow'
|
||||
| 'composite_bow'
|
||||
| 'warbow'
|
||||
| 'shortbow'
|
||||
| 'longbow'
|
||||
| 'orc_bow';
|
||||
|
||||
/** Maps to Kampftalent id in talents (Fernkampf subtypes). */
|
||||
export type RangedSkillId = 'bogen' | 'armbrust' | 'wurfwaffe' | 'schleuder';
|
||||
|
||||
export type RangedWeaponDef = {
|
||||
id: RangedWeaponId;
|
||||
name: string;
|
||||
skill: RangedSkillId;
|
||||
damage: string;
|
||||
/** Entfernungs-Bonusschaden pro Reichweitenstufe (nah → extrem weit), slash-getrennt */
|
||||
bonusDamage: string;
|
||||
/** Reichweiten in Schritt, slash-getrennt */
|
||||
ranges: string;
|
||||
reload: number;
|
||||
/** Mindest-KK für ungehinderten Einsatz (optional) */
|
||||
strengthRequirement?: number;
|
||||
/** Erschwernis auf FK, wenn KK unter Anforderung */
|
||||
strengthModifier?: number;
|
||||
};
|
||||
|
||||
export const RANGED_WEAPONS: RangedWeaponDef[] = [
|
||||
{
|
||||
id: 'elven_bow',
|
||||
name: 'Elfenbogen',
|
||||
skill: 'bogen',
|
||||
damage: '1W6+5',
|
||||
bonusDamage: '3/2/1/1/0',
|
||||
ranges: '10/25/50/100/200',
|
||||
reload: 3
|
||||
},
|
||||
{
|
||||
id: 'composite_bow',
|
||||
name: 'Kompositbogen',
|
||||
skill: 'bogen',
|
||||
damage: '1W6+5',
|
||||
bonusDamage: '2/1/1/0/0',
|
||||
ranges: '10/20/35/50/80',
|
||||
reload: 3,
|
||||
strengthRequirement: 15,
|
||||
strengthModifier: -2
|
||||
},
|
||||
{
|
||||
id: 'warbow',
|
||||
name: 'Kriegsbogen',
|
||||
skill: 'bogen',
|
||||
damage: '1W6+7',
|
||||
bonusDamage: '3/2/1/0/0',
|
||||
ranges: '10/20/40/80/150',
|
||||
reload: 4,
|
||||
strengthRequirement: 16,
|
||||
strengthModifier: -2
|
||||
},
|
||||
{
|
||||
id: 'shortbow',
|
||||
name: 'Kurzbogen',
|
||||
skill: 'bogen',
|
||||
damage: '1W6+4',
|
||||
bonusDamage: '1/1/0/0/-1',
|
||||
ranges: '5/15/25/40/60',
|
||||
reload: 2
|
||||
},
|
||||
{
|
||||
id: 'longbow',
|
||||
name: 'Langbogen',
|
||||
skill: 'bogen',
|
||||
damage: '1W6+6',
|
||||
bonusDamage: '3/2/1/0/-1',
|
||||
ranges: '10/25/50/100/200',
|
||||
reload: 4,
|
||||
strengthRequirement: 15,
|
||||
strengthModifier: -2
|
||||
},
|
||||
{
|
||||
id: 'orc_bow',
|
||||
name: 'Ork. Reiterbogen',
|
||||
skill: 'bogen',
|
||||
damage: '1W6+5',
|
||||
bonusDamage: '3/1/0/-1/-2',
|
||||
ranges: '5/15/30/60/100',
|
||||
reload: 3,
|
||||
strengthRequirement: 15,
|
||||
strengthModifier: -2
|
||||
}
|
||||
];
|
||||
|
||||
export function getRangedWeapon(id: RangedWeaponId): RangedWeaponDef | undefined {
|
||||
return RANGED_WEAPONS.find((w) => w.id === id);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { z } from 'zod';
|
||||
import { CURRENT_SCHEMA_VERSION } from '$lib/schema/version';
|
||||
|
||||
const attributeSchema = z.object({
|
||||
startwert: z.number().int().min(1).max(25),
|
||||
mod: z.number().int().min(-99).max(99)
|
||||
});
|
||||
|
||||
const energySchema = z.object({
|
||||
mod: z.number().int(),
|
||||
permanent: z.number().int().optional(),
|
||||
aktuell: z.number().int().optional()
|
||||
});
|
||||
|
||||
const traitSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
note: z.string().optional()
|
||||
});
|
||||
|
||||
const sfSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
note: z.string().optional()
|
||||
});
|
||||
|
||||
const talentEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
taw: z.number().int(),
|
||||
be: z.number().int().optional(),
|
||||
spezialisierungen: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
const combatTalentSchema = z.object({
|
||||
id: z.string(),
|
||||
taw: z.number().int(),
|
||||
at: z.number().int(),
|
||||
pa: z.number().int()
|
||||
});
|
||||
|
||||
const itemSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
anzahl: z.number().int().positive().default(1),
|
||||
note: z.string().optional()
|
||||
});
|
||||
|
||||
const rangedWeaponInstanceSchema = z.object({
|
||||
instanceId: z.string(),
|
||||
weaponDefId: z.string(),
|
||||
name: z.string().optional(),
|
||||
note: z.string().optional()
|
||||
});
|
||||
|
||||
const meleeWeaponInstanceSchema = z.object({
|
||||
instanceId: z.string(),
|
||||
name: z.string(),
|
||||
/** Kampftalent id (z.B. schwerter) */
|
||||
talentId: z.string(),
|
||||
atMod: z.number().int().default(0),
|
||||
paMod: z.number().int().default(0),
|
||||
damage: z.string().optional(),
|
||||
note: z.string().optional()
|
||||
});
|
||||
|
||||
const spellEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
stufe: z.number().int().min(1).max(25),
|
||||
zfp: z.number().int().optional(),
|
||||
note: z.string().optional()
|
||||
});
|
||||
|
||||
const liturgyEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
stufe: z.number().int().min(1).max(25),
|
||||
note: z.string().optional()
|
||||
});
|
||||
|
||||
const eigenschaftenSchema = z.object({
|
||||
MU: attributeSchema,
|
||||
KL: attributeSchema,
|
||||
IN: attributeSchema,
|
||||
CH: attributeSchema,
|
||||
FF: attributeSchema,
|
||||
GE: attributeSchema,
|
||||
KO: attributeSchema,
|
||||
KK: attributeSchema
|
||||
});
|
||||
|
||||
export const characterSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
schemaVersion: z.literal(CURRENT_SCHEMA_VERSION),
|
||||
meta: z.object({
|
||||
name: z.string().min(1),
|
||||
rasse: z.string(),
|
||||
kultur: z.string(),
|
||||
profession: z.string(),
|
||||
geschlecht: z.enum(['m', 'w', 'd']).optional(),
|
||||
gp: z.number().int().optional()
|
||||
}),
|
||||
eigenschaften: eigenschaftenSchema,
|
||||
energien: z.object({
|
||||
lep: energySchema,
|
||||
asp: energySchema.optional(),
|
||||
kap: energySchema.optional(),
|
||||
aup: energySchema,
|
||||
mr: energySchema
|
||||
}),
|
||||
vorteile: z.array(traitSchema).default([]),
|
||||
nachteile: z.array(traitSchema).default([]),
|
||||
sonderfertigkeiten: z.array(sfSchema).default([]),
|
||||
talente: z.array(talentEntrySchema).default([]),
|
||||
kampftalente: z.array(combatTalentSchema).default([]),
|
||||
zauber: z.array(spellEntrySchema).optional(),
|
||||
liturgien: z.array(liturgyEntrySchema).optional(),
|
||||
inventar: z.array(itemSchema).default([]),
|
||||
waffen: z.object({
|
||||
fernkampf: z.array(rangedWeaponInstanceSchema).default([]),
|
||||
nahkampf: z.array(meleeWeaponInstanceSchema).default([])
|
||||
}),
|
||||
notizen: z.string().optional()
|
||||
});
|
||||
|
||||
export type Character = z.infer<typeof characterSchema>;
|
||||
|
||||
export function parseCharacter(data: unknown): Character {
|
||||
return characterSchema.parse(data);
|
||||
}
|
||||
|
||||
export function safeParseCharacter(data: unknown) {
|
||||
return characterSchema.safeParse(data);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const CURRENT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type SchemaVersion = typeof CURRENT_SCHEMA_VERSION;
|
||||
|
||||
export function migrateCharacter(raw: unknown): unknown {
|
||||
if (typeof raw !== 'object' || raw === null) return raw;
|
||||
const o = raw as Record<string, unknown>;
|
||||
const v = o.schemaVersion;
|
||||
if (v === undefined) {
|
||||
return { ...o, schemaVersion: CURRENT_SCHEMA_VERSION };
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
|
||||
type MetaRow = { key: string; value: string };
|
||||
|
||||
export class DsaDB extends Dexie {
|
||||
characters!: Table<Character, string>;
|
||||
meta!: Table<MetaRow, string>;
|
||||
|
||||
constructor() {
|
||||
super('dsa-ranger');
|
||||
this.version(1).stores({
|
||||
characters: 'id, meta.name',
|
||||
meta: 'key'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new DsaDB();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import yaml from 'js-yaml';
|
||||
import { parseCharacter } from '$lib/schema/character';
|
||||
import { migrateCharacter } from '$lib/schema/version';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
|
||||
export function exportCharacterJson(c: Character, pretty = true): string {
|
||||
return pretty ? JSON.stringify(c, null, 2) : JSON.stringify(c);
|
||||
}
|
||||
|
||||
export function exportCharacterYaml(c: Character): string {
|
||||
return yaml.dump(c, { lineWidth: 100, noRefs: true });
|
||||
}
|
||||
|
||||
export function importCharacterFromText(text: string, format: 'json' | 'yaml'): Character {
|
||||
const raw =
|
||||
format === 'json' ? (JSON.parse(text) as unknown) : (yaml.load(text) as unknown);
|
||||
const migrated = migrateCharacter(raw);
|
||||
return parseCharacter(migrated);
|
||||
}
|
||||
|
||||
export function downloadText(filename: string, content: string, mime: string): void {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { db } from '$lib/storage/db';
|
||||
|
||||
const ACTIVE_KEY = 'activeCharacterId';
|
||||
|
||||
export async function listCharacters(): Promise<Character[]> {
|
||||
return db.characters.orderBy('meta.name').toArray();
|
||||
}
|
||||
|
||||
export async function getCharacter(id: string): Promise<Character | undefined> {
|
||||
return db.characters.get(id);
|
||||
}
|
||||
|
||||
export async function saveCharacter(c: Character): Promise<void> {
|
||||
await db.characters.put(c);
|
||||
}
|
||||
|
||||
export async function deleteCharacter(id: string): Promise<void> {
|
||||
await db.characters.delete(id);
|
||||
const active = await getActiveCharacterId();
|
||||
if (active === id) await setActiveCharacterId(null);
|
||||
}
|
||||
|
||||
export async function getActiveCharacterId(): Promise<string | null> {
|
||||
const row = await db.meta.get(ACTIVE_KEY);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveCharacterId(id: string | null): Promise<void> {
|
||||
if (id === null) await db.meta.delete(ACTIVE_KEY);
|
||||
else await db.meta.put({ key: ACTIVE_KEY, value: id });
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
let online = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
||||
let deferredPrompt: Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: string }> } | null =
|
||||
null;
|
||||
let canInstall = false;
|
||||
|
||||
onMount(() => {
|
||||
const updateOnline = () => {
|
||||
online = navigator.onLine;
|
||||
};
|
||||
window.addEventListener('online', updateOnline);
|
||||
window.addEventListener('offline', updateOnline);
|
||||
|
||||
const off = registerSW({
|
||||
immediate: true,
|
||||
onOfflineReady() {
|
||||
console.info('[PWA] Offline bereit');
|
||||
}
|
||||
});
|
||||
|
||||
const onBip = (e: Event) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e as typeof deferredPrompt;
|
||||
canInstall = true;
|
||||
};
|
||||
window.addEventListener('beforeinstallprompt', onBip);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', updateOnline);
|
||||
window.removeEventListener('offline', updateOnline);
|
||||
window.removeEventListener('beforeinstallprompt', onBip);
|
||||
if (typeof off === 'function') off();
|
||||
};
|
||||
});
|
||||
|
||||
async function installApp() {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
deferredPrompt = null;
|
||||
canInstall = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>DSA 4.1 Helfer</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<a href="/">DSA 4.1 Helfer</a>
|
||||
<span class="pill" class:bad={!online} class:ok={online}>
|
||||
{online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<a href="/" class:active={$page.url.pathname === '/'}>Helden</a>
|
||||
<a href="/settings" class:active={$page.url.pathname.startsWith('/settings')}>Einstellungen</a>
|
||||
{#if canInstall}
|
||||
<button type="button" class="btn primary" on:click={installApp}>Installieren</button>
|
||||
{/if}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.brand a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.pill.ok {
|
||||
border-color: #2d6a4f;
|
||||
color: #95d5b2;
|
||||
}
|
||||
.pill.bad {
|
||||
border-color: #8a3d3d;
|
||||
color: #ffb4b4;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.nav a {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.nav a.active {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.main {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { newCharacter } from '$lib/characters/default';
|
||||
import { deleteCharacter, listCharacters, saveCharacter, setActiveCharacterId } from '$lib/storage/repo';
|
||||
import { importCharacterFromText, downloadText, exportCharacterJson, exportCharacterYaml } from '$lib/storage/io';
|
||||
|
||||
let characters: Character[] = [];
|
||||
let importError = '';
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
|
||||
async function refresh() {
|
||||
characters = await listCharacters();
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function createHero() {
|
||||
const c = newCharacter();
|
||||
await saveCharacter(c);
|
||||
await setActiveCharacterId(c.id);
|
||||
await refresh();
|
||||
await goto(`/characters/${c.id}`);
|
||||
}
|
||||
|
||||
async function remove(id: string, ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!confirm('Held wirklich löschen?')) return;
|
||||
await deleteCharacter(id);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function exportJson(c: Character) {
|
||||
const safe = c.meta.name.replace(/[^\wäöüÄÖÜß-]+/gi, '_');
|
||||
downloadText(`${safe}-${new Date().toISOString().slice(0, 10)}.dsa.json`, exportCharacterJson(c), 'application/json');
|
||||
}
|
||||
|
||||
function exportYaml(c: Character) {
|
||||
const safe = c.meta.name.replace(/[^\wäöüÄÖÜß-]+/gi, '_');
|
||||
downloadText(`${safe}-${new Date().toISOString().slice(0, 10)}.dsa.yaml`, exportCharacterYaml(c), 'text/yaml');
|
||||
}
|
||||
|
||||
async function onImportPick(ev: Event) {
|
||||
importError = '';
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
try {
|
||||
const fmt = file.name.endsWith('.yaml') || file.name.endsWith('.yml') ? 'yaml' : 'json';
|
||||
const c = importCharacterFromText(text, fmt);
|
||||
await saveCharacter(c);
|
||||
await setActiveCharacterId(c.id);
|
||||
await refresh();
|
||||
await goto(`/characters/${c.id}`);
|
||||
} catch (e) {
|
||||
importError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Helden</h1>
|
||||
<p class="muted">Lokal gespeichert (IndexedDB). Export/Import als JSON oder YAML.</p>
|
||||
|
||||
<div class="row" style="margin: 1rem 0;">
|
||||
<button type="button" class="btn primary" on:click={createHero}>Neuer Held</button>
|
||||
<button type="button" class="btn" on:click={() => fileInput?.click()}>Import …</button>
|
||||
<input bind:this={fileInput} type="file" accept=".json,.yaml,.yml,application/json" hidden on:change={onImportPick} />
|
||||
</div>
|
||||
|
||||
{#if importError}
|
||||
<p class="err">{importError}</p>
|
||||
{/if}
|
||||
|
||||
{#if characters.length === 0}
|
||||
<div class="card">
|
||||
<p>Noch keine Helden. Lege einen neuen an oder importiere eine Datei.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each characters as c (c.id)}
|
||||
<li class="card hero">
|
||||
<a class="hero-link" href="/characters/{c.id}">
|
||||
<strong>{c.meta.name}</strong>
|
||||
<span class="muted">{c.meta.profession || '—'} · {c.meta.rasse}</span>
|
||||
</a>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn" on:click={() => exportJson(c)}>JSON</button>
|
||||
<button type="button" class="btn" on:click={() => exportYaml(c)}>YAML</button>
|
||||
<button type="button" class="btn danger" on:click={(e) => remove(c.id, e)}>Löschen</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.hero-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.hero-link:hover strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export const prerender = false;
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { getCharacter, setActiveCharacterId } from '$lib/storage/repo';
|
||||
import { computeDerived } from '$lib/engine/derived';
|
||||
|
||||
let char: Character | undefined;
|
||||
let err = '';
|
||||
|
||||
const id = $page.params.id ?? '';
|
||||
|
||||
onMount(async () => {
|
||||
if (!id) {
|
||||
err = 'Ungültige URL.';
|
||||
return;
|
||||
}
|
||||
char = await getCharacter(id);
|
||||
if (!char) {
|
||||
err = 'Held nicht gefunden.';
|
||||
return;
|
||||
}
|
||||
await setActiveCharacterId(char.id);
|
||||
});
|
||||
|
||||
$: derived = char ? computeDerived(char) : null;
|
||||
</script>
|
||||
|
||||
{#if err}
|
||||
<p class="err">{err}</p>
|
||||
<p><a href="/">Zurück</a></p>
|
||||
{:else if !char}
|
||||
<p>Lade …</p>
|
||||
{:else}
|
||||
<h1>{char.meta.name}</h1>
|
||||
<p class="muted">{char.meta.profession} · {char.meta.rasse}</p>
|
||||
|
||||
{#if derived}
|
||||
<div class="grid-2" style="margin-top: 1rem;">
|
||||
<div class="card">
|
||||
<h2>Basis</h2>
|
||||
<ul class="kv">
|
||||
<li><span>AT-Basis</span><strong>{derived.atBasis}</strong></li>
|
||||
<li><span>PA-Basis</span><strong>{derived.paBasis}</strong></li>
|
||||
<li><span>FK-Basis</span><strong>{derived.fkBasis}</strong></li>
|
||||
<li><span>INI-Basis</span><strong>{derived.iniBasis}</strong></li>
|
||||
<li><span>MR-Basis</span><strong>{derived.mrBasis}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Energien (Max)</h2>
|
||||
<ul class="kv">
|
||||
<li><span>LeP</span><strong>{derived.lepMax}</strong></li>
|
||||
<li><span>AsP</span><strong>{derived.aspMax}</strong></li>
|
||||
<li><span>AuP</span><strong>{derived.aupMax}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<nav class="subnav card">
|
||||
<a href="/characters/{char.id}/sheet">Bogen</a>
|
||||
<a href="/characters/{char.id}/combat/ranged">Fernkampf</a>
|
||||
<a href="/characters/{char.id}/combat/melee">Nahkampf</a>
|
||||
<a href="/characters/{char.id}/spells">Magie</a>
|
||||
<a href="/">Alle Helden</a>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.subnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.kv {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.kv li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.kv span {
|
||||
color: var(--muted);
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { getCharacter } from '$lib/storage/repo';
|
||||
import { computeMeleeTargets } from '$lib/engine/melee';
|
||||
import { TALENTS } from '$lib/rules/talents';
|
||||
|
||||
let char: Character | undefined;
|
||||
let err = '';
|
||||
const id = $page.params.id ?? '';
|
||||
|
||||
let talentId = 'dolche';
|
||||
let weaponIndex = 0;
|
||||
let situation = '0';
|
||||
let atExtra = 0;
|
||||
let paExtra = 0;
|
||||
let ansage = 0;
|
||||
|
||||
onMount(async () => {
|
||||
if (!id) {
|
||||
err = 'Ungültige URL.';
|
||||
return;
|
||||
}
|
||||
char = await getCharacter(id);
|
||||
if (!char) err = 'Held nicht gefunden.';
|
||||
else if (char.waffen.nahkampf.length) {
|
||||
talentId = char.waffen.nahkampf[0].talentId;
|
||||
}
|
||||
});
|
||||
|
||||
$: wIdx = Number(weaponIndex);
|
||||
$: weapon = char?.waffen.nahkampf[wIdx];
|
||||
$: result =
|
||||
char &&
|
||||
weapon &&
|
||||
computeMeleeTargets(
|
||||
char,
|
||||
talentId,
|
||||
weapon.atMod,
|
||||
weapon.paMod,
|
||||
{
|
||||
situationModifiers: situation
|
||||
.split(/[,;]+/)
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => Number.isFinite(n)),
|
||||
atExtra,
|
||||
paExtra,
|
||||
ansageAtAnteil: ansage
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if err}
|
||||
<p class="err">{err}</p>
|
||||
{:else if !char}
|
||||
<p>Lade …</p>
|
||||
{:else}
|
||||
<h1>Nahkampf · {char.meta.name}</h1>
|
||||
<p class="muted">
|
||||
Wähle Waffe und Erschwernisse (Zahlen). Manöver-Details am Tisch – hier nur Summen.
|
||||
</p>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Waffe</h2>
|
||||
{#if char.waffen.nahkampf.length === 0}
|
||||
<p>Keine Nahkampfwaffe im Bogen. Unter „Bogen“ anlegen.</p>
|
||||
{:else}
|
||||
<select bind:value={weaponIndex}>
|
||||
{#each char.waffen.nahkampf as w, i}
|
||||
<option value={i}>{w.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="field">
|
||||
<label>Kampftalent für diese Attacke</label>
|
||||
<select bind:value={talentId}>
|
||||
{#each TALENTS.filter((t) => t.category === 'kampf') as t}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Situation</h2>
|
||||
<div class="field">
|
||||
<label>Zusätzliche Erschwernisse (Komma-getrennt, z.B. 3,2)</label>
|
||||
<input bind:value={situation} placeholder="0" />
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>AT-Zusatz</label>
|
||||
<input type="number" bind:value={atExtra} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>PA-Zusatz</label>
|
||||
<input type="number" bind:value={paExtra} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ansage (AT-Anteil, vereinfacht)</label>
|
||||
<input type="number" min="0" bind:value={ansage} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if result}
|
||||
<section class="card stack highlight">
|
||||
<h2>Zielzahlen</h2>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<p class="muted">Attacke</p>
|
||||
<p class="big-number">{result.atTarget}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="muted">Parade</p>
|
||||
<p class="big-number">{result.paTarget}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="kv">
|
||||
<li><span>AT (inkl. Waffe)</span><strong>{result.at}</strong></li>
|
||||
<li><span>PA (inkl. Waffe)</span><strong>{result.pa}</strong></li>
|
||||
<li><span>Summe Modifikatoren</span><strong>{result.modifierSum}</strong></li>
|
||||
</ul>
|
||||
{#each result.notes as n}
|
||||
<p class="muted">{n}</p>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<p><a href="/characters/{char.id}">Zurück</a></p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.highlight {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
.kv {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.kv li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,267 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { getCharacter } from '$lib/storage/repo';
|
||||
import { computeRangedTarget, type Expertise } from '$lib/engine/ranged';
|
||||
import { RANGED_WEAPONS, type RangedWeaponId } from '$lib/rules/weapons-ranged';
|
||||
import {
|
||||
RANGES,
|
||||
TARGET_SIZES,
|
||||
VISIBILITY,
|
||||
TARGET_MOVEMENT,
|
||||
HIT_ZONES,
|
||||
OTHER_MODIFIERS,
|
||||
RELOAD_STATES,
|
||||
type ExtraModifierId,
|
||||
type HitZoneId,
|
||||
type RangeId,
|
||||
type ReloadStateId
|
||||
} from '$lib/rules/modifiers-ranged';
|
||||
|
||||
let char: Character | undefined;
|
||||
let err = '';
|
||||
const id = $page.params.id ?? '';
|
||||
|
||||
let weaponId: RangedWeaponId = 'shortbow';
|
||||
let expertise: Expertise = 'none';
|
||||
let range: RangeId = 'near';
|
||||
let targetSize = 'medium' as const;
|
||||
let visibility = 'clear' as const;
|
||||
let movement = 'slow' as const;
|
||||
let hitZone = '' as HitZoneId | '';
|
||||
let reloadState = '' as ReloadStateId | '';
|
||||
let targetBuild: 'biped' | 'quadruped' = 'biped';
|
||||
let aimTurns = 0;
|
||||
let extras = new Set<ExtraModifierId>();
|
||||
|
||||
onMount(async () => {
|
||||
if (!id) {
|
||||
err = 'Ungültige URL.';
|
||||
return;
|
||||
}
|
||||
char = await getCharacter(id);
|
||||
if (!char) err = 'Held nicht gefunden.';
|
||||
else {
|
||||
const first = char.waffen.fernkampf[0];
|
||||
if (first?.weaponDefId) weaponId = first.weaponDefId as RangedWeaponId;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleExtra(id: ExtraModifierId) {
|
||||
if (extras.has(id)) extras.delete(id);
|
||||
else extras.add(id);
|
||||
extras = extras;
|
||||
}
|
||||
|
||||
$: result =
|
||||
char &&
|
||||
(() => {
|
||||
try {
|
||||
return computeRangedTarget(
|
||||
char,
|
||||
weaponId,
|
||||
{
|
||||
range,
|
||||
targetSize,
|
||||
visibility,
|
||||
movement,
|
||||
hitZone: hitZone === '' ? undefined : hitZone,
|
||||
reloadState: reloadState || undefined,
|
||||
extras: [...extras],
|
||||
aimTurns,
|
||||
targetBuild
|
||||
},
|
||||
expertise
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const hitZoneOptions = HIT_ZONES.filter((z) => z.build === targetBuild);
|
||||
</script>
|
||||
|
||||
{#if err}
|
||||
<p class="err">{err}</p>
|
||||
{:else if !char}
|
||||
<p>Lade …</p>
|
||||
{:else}
|
||||
<h1>Fernkampf · {char.meta.name}</h1>
|
||||
<p class="muted">Wähle Modifikatoren – Zielzahl für 1W20 am Tisch.</p>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Waffe</h2>
|
||||
<select bind:value={weaponId}>
|
||||
{#each RANGED_WEAPONS as w}
|
||||
<option value={w.id}>{w.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Schützen-SF</h2>
|
||||
<select bind:value={expertise}>
|
||||
<option value="none">keine</option>
|
||||
<option value="expert">Scharfschütze (expert)</option>
|
||||
<option value="master">Meisterschütze (master)</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Ziel</h2>
|
||||
<div class="field">
|
||||
<label>Körperbau</label>
|
||||
<select bind:value={targetBuild}>
|
||||
<option value="biped">Zweibeiner</option>
|
||||
<option value="quadruped">Vierfüßer</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Entfernung</h2>
|
||||
<div class="chips">
|
||||
{#each RANGES as r}
|
||||
<label class="chip"
|
||||
><input type="radio" bind:group={range} value={r.id} /> {r.name} ({r.modifier})</label
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Zielgröße</h2>
|
||||
<div class="chips">
|
||||
{#each TARGET_SIZES as r}
|
||||
<label class="chip"
|
||||
><input type="radio" bind:group={targetSize} value={r.id} /> {r.name}</label
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Sicht</h2>
|
||||
<div class="chips">
|
||||
{#each VISIBILITY as r}
|
||||
<label class="chip"
|
||||
><input type="radio" bind:group={visibility} value={r.id} /> {r.name}</label
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Bewegung</h2>
|
||||
<div class="chips">
|
||||
{#each TARGET_MOVEMENT as r}
|
||||
<label class="chip"
|
||||
><input type="radio" bind:group={movement} value={r.id} /> {r.name}</label
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Trefferzone (optional)</h2>
|
||||
<select bind:value={hitZone}>
|
||||
<option value="">— keine —</option>
|
||||
{#each hitZoneOptions as z}
|
||||
<option value={z.id}>{z.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Schussart / Zustand</h2>
|
||||
<select bind:value={reloadState}>
|
||||
<option value="">— Standard —</option>
|
||||
{#each RELOAD_STATES as r}
|
||||
<option value={r.id}>{r.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="field">
|
||||
<label>Zielen (KR)</label>
|
||||
<input type="number" min="0" max="20" bind:value={aimTurns} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Weitere Modifikatoren</h2>
|
||||
<div class="chips">
|
||||
{#each OTHER_MODIFIERS as r}
|
||||
<label class="chip"
|
||||
><input
|
||||
type="checkbox"
|
||||
checked={extras.has(r.id as ExtraModifierId)}
|
||||
on:change={() => toggleExtra(r.id as ExtraModifierId)}
|
||||
/>
|
||||
{r.name}</label
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if result}
|
||||
<section class="card stack highlight">
|
||||
<h2>Ergebnis</h2>
|
||||
<p class="big-number">{result.finalTarget}</p>
|
||||
<p class="muted">Zielzahl (1W20): ≤ {result.finalTarget} ist Erfolg</p>
|
||||
<ul class="kv">
|
||||
<li><span>FK-Basis</span><strong>{result.fkBase}</strong></li>
|
||||
<li><span>TaW (Bogen)</span><strong>{result.weaponTaW}</strong></li>
|
||||
<li><span>wirksames TaW</span><strong>{result.effectiveTaW}</strong></li>
|
||||
<li><span>Basis (FK+TaW)</span><strong>{result.baseTarget}</strong></li>
|
||||
<li><span>Summe Modifikatoren</span><strong>{result.totalModifier}</strong></li>
|
||||
</ul>
|
||||
{#if result.reloadNote}<p class="muted">{result.reloadNote}</p>{/if}
|
||||
{#if result.strengthNote}<p class="muted">{result.strengthNote}</p>{/if}
|
||||
<h3>Modifikatoren</h3>
|
||||
<ul class="mods">
|
||||
{#each result.modifiers as m}
|
||||
<li><span>{m.label}</span><strong>{m.value}</strong></li>
|
||||
{/each}
|
||||
</ul>
|
||||
<h3>Schaden (Hinweis)</h3>
|
||||
<p>{result.damageSummary}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<p><a href="/characters/{char.id}">Zurück</a></p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.highlight {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
.kv,
|
||||
.mods {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.kv li,
|
||||
.mods li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { ATTRIBUTE_KEYS, ATTRIBUTE_LABELS } from '$lib/rules/attributes';
|
||||
import { getCharacter, saveCharacter } from '$lib/storage/repo';
|
||||
import { TALENTS } from '$lib/rules/talents';
|
||||
import { computeDerived } from '$lib/engine/derived';
|
||||
import { atBasis, paBasis } from '$lib/engine/derived';
|
||||
|
||||
let char: Character | undefined;
|
||||
let err = '';
|
||||
let status = '';
|
||||
const id = $page.params.id ?? '';
|
||||
|
||||
onMount(async () => {
|
||||
if (!id) {
|
||||
err = 'Ungültige URL.';
|
||||
return;
|
||||
}
|
||||
char = await getCharacter(id);
|
||||
if (!char) err = 'Held nicht gefunden.';
|
||||
});
|
||||
|
||||
async function persist() {
|
||||
if (!char) return;
|
||||
status = 'Speichere …';
|
||||
await saveCharacter(char);
|
||||
status = 'Gespeichert.';
|
||||
setTimeout(() => (status = ''), 1500);
|
||||
}
|
||||
|
||||
function syncKampfAtPa() {
|
||||
if (!char) return;
|
||||
const ab = atBasis(char);
|
||||
const pb = paBasis(char);
|
||||
for (const kt of char.kampftalente) {
|
||||
kt.at = ab + kt.taw;
|
||||
kt.pa = pb + kt.taw;
|
||||
}
|
||||
}
|
||||
|
||||
function addTalent() {
|
||||
if (!char) return;
|
||||
char.talente = [...char.talente, { id: 'athletik', taw: 0 }];
|
||||
char = char;
|
||||
}
|
||||
|
||||
function addKampf() {
|
||||
if (!char) return;
|
||||
const ab = atBasis(char);
|
||||
const pb = paBasis(char);
|
||||
char.kampftalente = [...char.kampftalente, { id: 'hiebwaffen', taw: 0, at: ab, pa: pb }];
|
||||
char = char;
|
||||
}
|
||||
|
||||
function addVorteil() {
|
||||
if (!char) return;
|
||||
char.vorteile = [...char.vorteile, { id: crypto.randomUUID(), name: '' }];
|
||||
char = char;
|
||||
}
|
||||
|
||||
function addNachteil() {
|
||||
if (!char) return;
|
||||
char.nachteile = [...char.nachteile, { id: crypto.randomUUID(), name: '' }];
|
||||
char = char;
|
||||
}
|
||||
|
||||
function addSF() {
|
||||
if (!char) return;
|
||||
char.sonderfertigkeiten = [...char.sonderfertigkeiten, { id: crypto.randomUUID(), name: '' }];
|
||||
char = char;
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (!char) return;
|
||||
char.inventar = [...char.inventar, { id: crypto.randomUUID(), name: '', anzahl: 1 }];
|
||||
char = char;
|
||||
}
|
||||
|
||||
function addMeleeWeapon() {
|
||||
if (!char) return;
|
||||
char.waffen.nahkampf = [
|
||||
...char.waffen.nahkampf,
|
||||
{
|
||||
instanceId: crypto.randomUUID(),
|
||||
name: 'Waffe',
|
||||
talentId: 'dolche',
|
||||
atMod: 0,
|
||||
paMod: 0
|
||||
}
|
||||
];
|
||||
char = char;
|
||||
}
|
||||
|
||||
$: derived = char ? computeDerived(char) : null;
|
||||
</script>
|
||||
|
||||
{#if err}
|
||||
<p class="err">{err}</p>
|
||||
{:else if !char}
|
||||
<p>Lade …</p>
|
||||
{:else}
|
||||
<div class="head">
|
||||
<h1>Bogen · {char.meta.name}</h1>
|
||||
<button type="button" class="btn primary" on:click={persist}>Speichern</button>
|
||||
</div>
|
||||
{#if status}<p class="muted">{status}</p>{/if}
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Stammdaten</h2>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" bind:value={char.meta.name} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="rasse">Rasse</label>
|
||||
<input id="rasse" bind:value={char.meta.rasse} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kultur">Kultur</label>
|
||||
<input id="kultur" bind:value={char.meta.kultur} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="prof">Profession</label>
|
||||
<input id="prof" bind:value={char.meta.profession} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Eigenschaften</h2>
|
||||
<div class="grid-2">
|
||||
{#each ATTRIBUTE_KEYS as key}
|
||||
<div class="field">
|
||||
<label for={key}>{ATTRIBUTE_LABELS[key]}</label>
|
||||
<div class="row">
|
||||
<input
|
||||
id="{key}-s"
|
||||
type="number"
|
||||
bind:value={char.eigenschaften[key].startwert}
|
||||
aria-label="{ATTRIBUTE_LABELS[key]} Start"
|
||||
/>
|
||||
<input
|
||||
id="{key}-m"
|
||||
type="number"
|
||||
bind:value={char.eigenschaften[key].mod}
|
||||
aria-label="{ATTRIBUTE_LABELS[key]} Mod"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Energien</h2>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>LeP Mod</label>
|
||||
<input type="number" bind:value={char.energien.lep.mod} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>AuP Mod</label>
|
||||
<input type="number" bind:value={char.energien.aup.mod} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>MR Mod</label>
|
||||
<input type="number" bind:value={char.energien.mr.mod} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>AsP Mod (optional)</label>
|
||||
{#if char.energien.asp}
|
||||
<input type="number" bind:value={char.energien.asp.mod} />
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
on:click={() => {
|
||||
char!.energien.asp = { mod: 0 };
|
||||
char = char;
|
||||
}}>AsP aktivieren</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if derived}
|
||||
<p class="muted">
|
||||
Berechnet: LeP-Max {derived.lepMax}, AsP-Max {derived.aspMax}, AuP-Max {derived.aupMax}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Kampftalente</h2>
|
||||
<button type="button" class="btn" on:click={syncKampfAtPa}>AT/PA aus Basis+TaW neu setzen</button>
|
||||
{#each char.kampftalente as kt, i}
|
||||
<div class="inline">
|
||||
<select bind:value={kt.id}>
|
||||
{#each TALENTS.filter((t) => t.category === 'kampf') as t}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label class="sr">TaW</label>
|
||||
<input class="w-tiny" type="number" bind:value={kt.taw} />
|
||||
<label class="sr">AT</label>
|
||||
<input class="w-tiny" type="number" bind:value={kt.at} />
|
||||
<label class="sr">PA</label>
|
||||
<input class="w-tiny" type="number" bind:value={kt.pa} />
|
||||
<button
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.kampftalente = char!.kampftalente.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addKampf}>Kampftalent</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Talente</h2>
|
||||
{#each char.talente as tal, i}
|
||||
<div class="inline">
|
||||
<select bind:value={tal.id}>
|
||||
{#each TALENTS as t}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input class="w-tiny" type="number" bind:value={tal.taw} />
|
||||
<button
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.talente = char!.talente.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addTalent}>Talent</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Vorteile</h2>
|
||||
{#each char.vorteile as v, i}
|
||||
<div class="inline">
|
||||
<input placeholder="Name" bind:value={v.name} />
|
||||
<button
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.vorteile = char!.vorteile.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addVorteil}>Vorteil</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Nachteile</h2>
|
||||
{#each char.nachteile as v, i}
|
||||
<div class="inline">
|
||||
<input placeholder="Name" bind:value={v.name} />
|
||||
<button
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.nachteile = char!.nachteile.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addNachteil}>Nachteil</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Sonderfertigkeiten</h2>
|
||||
{#each char.sonderfertigkeiten as s, i}
|
||||
<div class="inline">
|
||||
<input placeholder="Name" bind:value={s.name} />
|
||||
<button
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.sonderfertigkeiten = char!.sonderfertigkeiten.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addSF}>SF</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Inventar</h2>
|
||||
{#each char.inventar as it, i}
|
||||
<div class="inline">
|
||||
<input placeholder="Gegenstand" bind:value={it.name} />
|
||||
<input class="w-tiny" type="number" bind:value={it.anzahl} min="1" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.inventar = char!.inventar.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addItem}>Gegenstand</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Nahkampfwaffen (Instanzen)</h2>
|
||||
{#each char.waffen.nahkampf as w, i}
|
||||
<div class="block card" style="background: var(--surface-2)">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input bind:value={w.name} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Kampftalent</label>
|
||||
<select bind:value={w.talentId}>
|
||||
{#each TALENTS.filter((t) => t.category === 'kampf') as t}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>AT-Mod</label>
|
||||
<input type="number" bind:value={w.atMod} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>PA-Mod</label>
|
||||
<input type="number" bind:value={w.paMod} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.waffen.nahkampf = char!.waffen.nahkampf.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>Entfernen</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addMeleeWeapon}>Nahkampfwaffe</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Notizen</h2>
|
||||
<textarea rows="6" bind:value={char.notizen} placeholder="Freitext …"></textarea>
|
||||
</section>
|
||||
|
||||
<p><a href="/characters/{char.id}">Zur Übersicht</a></p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.w-tiny {
|
||||
width: 4.5rem;
|
||||
}
|
||||
.sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import type { AttributeKey } from '$lib/rules/attributes';
|
||||
import { getCharacter, saveCharacter } from '$lib/storage/repo';
|
||||
import { spellProbeTargets } from '$lib/engine/spell';
|
||||
|
||||
let char: Character | undefined;
|
||||
let err = '';
|
||||
const id = $page.params.id ?? '';
|
||||
|
||||
let zfw = 10;
|
||||
const defaultProbe: [AttributeKey, AttributeKey, AttributeKey] = ['MU', 'KL', 'CH'];
|
||||
|
||||
onMount(async () => {
|
||||
if (!id) {
|
||||
err = 'Ungültige URL.';
|
||||
return;
|
||||
}
|
||||
const loaded = await getCharacter(id);
|
||||
if (!loaded) {
|
||||
err = 'Held nicht gefunden.';
|
||||
return;
|
||||
}
|
||||
if (!loaded.zauber) loaded.zauber = [];
|
||||
char = loaded;
|
||||
});
|
||||
|
||||
async function persist() {
|
||||
if (!char) return;
|
||||
await saveCharacter(char);
|
||||
}
|
||||
|
||||
function addSpell() {
|
||||
if (!char) return;
|
||||
if (!char.zauber) char.zauber = [];
|
||||
char.zauber = [
|
||||
...char.zauber,
|
||||
{ id: crypto.randomUUID(), name: 'Neuer Zauber', stufe: 12, zfp: 0 }
|
||||
];
|
||||
char = char;
|
||||
}
|
||||
|
||||
function removeSpell(i: number) {
|
||||
if (!char?.zauber) return;
|
||||
char.zauber = char.zauber.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}
|
||||
|
||||
$: probe = char ? spellProbeTargets(char, defaultProbe, zfw) : null;
|
||||
</script>
|
||||
|
||||
{#if err}
|
||||
<p class="err">{err}</p>
|
||||
{:else if !char}
|
||||
<p>Lade …</p>
|
||||
{:else}
|
||||
<h1>Magie · {char.meta.name}</h1>
|
||||
<p class="muted">
|
||||
Zauberliste und vereinfachte Zielzahlen (MU/KL/CH + ZfW). Liturgien unten als Freitextliste.
|
||||
</p>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Probe (generisch)</h2>
|
||||
<p class="muted">Attribute: MU / KL / CH – im Regelwerk je Zauber anpassen.</p>
|
||||
<div class="field">
|
||||
<label>ZfW / ZfP-Ziel</label>
|
||||
<input type="number" bind:value={zfw} />
|
||||
</div>
|
||||
{#if probe}
|
||||
<ul class="kv">
|
||||
{#each probe.targets as t, i}
|
||||
<li
|
||||
><span>Würfel {i + 1} ({probe.attrs[i]})</span><strong>{t}</strong></li
|
||||
>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Zauber</h2>
|
||||
{#each char.zauber ?? [] as z, i}
|
||||
<div class="block card" style="background: var(--surface-2)">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input bind:value={z.name} />
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>Stufe</label>
|
||||
<input type="number" bind:value={z.stufe} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>ZfP gesamt</label>
|
||||
<input type="number" bind:value={z.zfp} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notiz</label>
|
||||
<input bind:value={z.note} />
|
||||
</div>
|
||||
<button type="button" class="btn danger" on:click={() => removeSpell(i)}>Entfernen</button>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="row">
|
||||
<button type="button" class="btn" on:click={addSpell}>Zauber</button>
|
||||
<button type="button" class="btn primary" on:click={persist}>Speichern</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Liturgien (Freitext)</h2>
|
||||
{#if !char.liturgien}
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
on:click={() => {
|
||||
char!.liturgien = [];
|
||||
char = char;
|
||||
}}>Liste aktivieren</button
|
||||
>
|
||||
{:else}
|
||||
{#each char.liturgien as lit, i}
|
||||
<div class="inline">
|
||||
<input bind:value={lit.name} />
|
||||
<input class="w-tiny" type="number" bind:value={lit.stufe} />
|
||||
<button
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.liturgien = char!.liturgien!.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
on:click={() => {
|
||||
char!.liturgien = [...(char!.liturgien ?? []), { id: crypto.randomUUID(), name: '', stufe: 1 }];
|
||||
char = char;
|
||||
}}>Liturgie</button
|
||||
>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<p><a href="/characters/{char.id}">Zurück</a></p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.kv {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.kv li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.w-tiny {
|
||||
width: 4rem;
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import DiceRoller from '$lib/components/DiceRoller.svelte';
|
||||
import { isHeldenImportSupported } from '$lib/import/helden';
|
||||
</script>
|
||||
|
||||
<h1>Einstellungen</h1>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Helden-Software</h2>
|
||||
<p class="muted">
|
||||
Import: {isHeldenImportSupported() ? 'verfügbar' : 'noch nicht implementiert'} (XML/.helden).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Würfel (optional)</h2>
|
||||
<p class="muted">Am Tisch würfeln reicht – hier nur für Schnelltests.</p>
|
||||
<DiceRoller />
|
||||
</section>
|
||||
|
||||
<p><a href="/">Zurück</a></p>
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
Reference in New Issue
Block a user