ranged combat refinement

This commit is contained in:
2026-05-14 16:47:20 +02:00
parent 6db1a4fcc0
commit bf5fe35d72
4 changed files with 159 additions and 57 deletions
+17 -2
View File
@@ -157,15 +157,30 @@ button {
color: var(--muted);
}
.field input,
.field select,
.field textarea {
padding: 0.45rem 0.55rem;
border-radius: 8px;
border: 1px solid var(--input-border);
background: var(--input-bg);
background-color: var(--input-bg);
color: var(--text);
}
/* Do not use `background:` shorthand on select — it clears the chevron from global `select` rules */
.field select {
padding: 0.45rem 0.55rem;
padding-right: 2rem;
border-radius: 8px;
border: 1px solid var(--input-border);
background-color: var(--input-bg);
background-image: var(--select-chevron);
background-repeat: no-repeat;
background-position: right 0.55rem center;
color: var(--text);
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
+5 -8
View File
@@ -55,12 +55,6 @@ 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);
@@ -198,9 +192,12 @@ export function computeRangedTarget(
const finalTarget = baseTarget - totalModifier;
const ri = rangeIndex(selections.range);
const bonus = parseBonusDamage(weapon.bonusDamage, ri);
const bonus =
ri >= 0 && ri < weapon.bonusDamageByRange.length ? weapon.bonusDamageByRange[ri] : 0;
const damageSummary =
bonus === 0 ? `${weapon.damage}` : `${weapon.damage} + ${bonus} (Entfernungs-Bonus)`;
bonus === 0
? `${weapon.damage}`
: `${weapon.damage} ${bonus > 0 ? `+ ${bonus}` : ` ${Math.abs(bonus)}`} (Entfernungs-Bonus)`;
return {
fkBase,
+27 -16
View File
@@ -9,15 +9,26 @@ export type RangedWeaponId =
/** Maps to Kampftalent id in talents (Fernkampf subtypes). */
export type RangedSkillId = 'bogen' | 'armbrust' | 'wurfwaffe' | 'schleuder';
/**
* Maximale Reichweite in Schritt je Fernkampf-Distanzstufe.
* Reihenfolge wie in der Regeltabelle / wie `RANGES` in `modifiers-ranged.ts`:
* sehr nah, nah, mittel, weit, extrem weit.
*/
export type WeaponRangeStepsSchritt = readonly [number, number, number, number, number];
/**
* Entfernungs-Bonusschaden (TP) je Distanzstufe.
* Reihenfolge wie `RANGES` / `rangeStepsSchritt`: sehr nah, nah, mittel, weit, extrem weit.
*/
export type WeaponBonusDamageByRange = readonly [number, number, number, number, number];
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;
bonusDamageByRange: WeaponBonusDamageByRange;
rangeStepsSchritt: WeaponRangeStepsSchritt;
reload: number;
/** Mindest-KK für ungehinderten Einsatz (optional) */
strengthRequirement?: number;
@@ -31,8 +42,8 @@ export const RANGED_WEAPONS: RangedWeaponDef[] = [
name: 'Elfenbogen',
skill: 'bogen',
damage: '1W6+5',
bonusDamage: '3/2/1/1/0',
ranges: '10/25/50/100/200',
bonusDamageByRange: [3, 2, 1, 1, 0],
rangeStepsSchritt: [10, 25, 50, 100, 200],
reload: 3
},
{
@@ -40,8 +51,8 @@ export const RANGED_WEAPONS: RangedWeaponDef[] = [
name: 'Kompositbogen',
skill: 'bogen',
damage: '1W6+5',
bonusDamage: '2/1/1/0/0',
ranges: '10/20/35/50/80',
bonusDamageByRange: [2, 1, 1, 0, 0],
rangeStepsSchritt: [10, 20, 35, 50, 80],
reload: 3,
strengthRequirement: 15,
strengthModifier: -2
@@ -51,8 +62,8 @@ export const RANGED_WEAPONS: RangedWeaponDef[] = [
name: 'Kriegsbogen',
skill: 'bogen',
damage: '1W6+7',
bonusDamage: '3/2/1/0/0',
ranges: '10/20/40/80/150',
bonusDamageByRange: [3, 2, 1, 0, 0],
rangeStepsSchritt: [10, 20, 40, 80, 150],
reload: 4,
strengthRequirement: 16,
strengthModifier: -2
@@ -62,8 +73,8 @@ export const RANGED_WEAPONS: RangedWeaponDef[] = [
name: 'Kurzbogen',
skill: 'bogen',
damage: '1W6+4',
bonusDamage: '1/1/0/0/-1',
ranges: '5/15/25/40/60',
bonusDamageByRange: [1, 1, 0, 0, -1],
rangeStepsSchritt: [5, 15, 25, 40, 60],
reload: 2
},
{
@@ -71,8 +82,8 @@ export const RANGED_WEAPONS: RangedWeaponDef[] = [
name: 'Langbogen',
skill: 'bogen',
damage: '1W6+6',
bonusDamage: '3/2/1/0/-1',
ranges: '10/25/50/100/200',
bonusDamageByRange: [3, 2, 1, 0, -1],
rangeStepsSchritt: [10, 25, 50, 100, 200],
reload: 4,
strengthRequirement: 15,
strengthModifier: -2
@@ -82,8 +93,8 @@ export const RANGED_WEAPONS: RangedWeaponDef[] = [
name: 'Ork. Reiterbogen',
skill: 'bogen',
damage: '1W6+5',
bonusDamage: '3/1/0/-1/-2',
ranges: '5/15/30/60/100',
bonusDamageByRange: [3, 1, 0, -1, -2],
rangeStepsSchritt: [5, 15, 30, 60, 100],
reload: 3,
strengthRequirement: 15,
strengthModifier: -2
@@ -4,7 +4,7 @@
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 { getRangedWeapon, RANGED_WEAPONS, type RangedWeaponId } from '$lib/rules/weapons-ranged';
import {
RANGES,
TARGET_SIZES,
@@ -15,10 +15,19 @@
RELOAD_STATES,
type ExtraModifierId,
type HitZoneId,
type ModifierRow,
type MovementId,
type RangeId,
type ReloadStateId
type ReloadStateId,
type TargetSizeId,
type VisibilityId
} from '$lib/rules/modifiers-ranged';
function optionLabel(row: ModifierRow): string {
const m = row.modifier;
return `${row.name} (${m})`;
}
let char: Character | undefined;
let err = '';
const id = $page.params.id ?? '';
@@ -26,9 +35,9 @@
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 targetSize: TargetSizeId = 'medium';
let visibility: VisibilityId = 'clear';
let movement: MovementId = 'slow';
let hitZone = '' as HitZoneId | '';
let reloadState = '' as ReloadStateId | '';
let targetBuild: 'biped' | 'quadruped' = 'biped';
@@ -79,7 +88,9 @@
}
})();
const hitZoneOptions = HIT_ZONES.filter((z) => z.build === targetBuild);
$: hitZoneOptions = HIT_ZONES.filter((z) => z.build === targetBuild);
$: weaponForExamples = getRangedWeapon(weaponId);
</script>
{#if err}
@@ -97,6 +108,16 @@
<option value={w.id}>{w.name}</option>
{/each}
</select>
{#if weaponForExamples}
<p class="weapon-stat-line muted">
<strong>Reichweiten:</strong>
{weaponForExamples.rangeStepsSchritt.join('/')}
</p>
<p class="weapon-stat-line muted">
<strong>TP+:</strong>
{weaponForExamples.bonusDamageByRange.join('/')}
</p>
{/if}
</section>
<section class="card stack">
@@ -121,45 +142,72 @@
<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 class="field">
<label for="range-select">Distanz</label>
<select id="range-select" bind:value={range}>
{#each RANGES as r}
<option value={r.id}>{optionLabel(r)}</option>
{/each}
</select>
</div>
{#if weaponForExamples}
<div class="ref-examples">
<p class="examples-label"><strong>Beispiele:</strong></p>
<ul class="examples-list muted">
{#each RANGES as r, i}
<li>
<span class="examples-name">{r.name}</span>
{weaponForExamples.rangeStepsSchritt[i]} Schritt
</li>
{/each}
</ul>
</div>
{/if}
</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 class="field">
<label for="target-size-select">Größe</label>
<select id="target-size-select" bind:value={targetSize}>
{#each TARGET_SIZES as r}
<option value={r.id}>{optionLabel(r)}</option>
{/each}
</select>
</div>
<div class="ref-examples">
<p class="examples-label"><strong>Beispiele:</strong></p>
<ul class="examples-list muted">
{#each TARGET_SIZES as r}
{#if r.description}
<li><span class="examples-name">{r.name}</span>{r.description}</li>
{/if}
{/each}
</ul>
</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 class="field">
<label for="visibility-select">Sichtverhältnisse</label>
<select id="visibility-select" bind:value={visibility}>
{#each VISIBILITY as r}
<option value={r.id}>{optionLabel(r)}</option>
{/each}
</select>
</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 class="field">
<label for="movement-select">Bewegung des Ziels</label>
<select id="movement-select" bind:value={movement}>
{#each TARGET_MOVEMENT as r}
<option value={r.id}>{optionLabel(r)}</option>
{/each}
</select>
</div>
</section>
@@ -264,4 +312,35 @@
.err {
color: var(--danger);
}
.ref-examples {
margin-top: 0.75rem;
}
.examples-label {
margin: 0 0 0.35rem;
font-size: 0.9rem;
color: var(--text);
}
.examples-list {
margin: 0;
padding-left: 1.15rem;
font-size: 0.88rem;
line-height: 1.45;
}
.examples-list li {
margin-bottom: 0.25rem;
}
.examples-name {
font-weight: 600;
color: var(--text);
}
.weapon-stat-line {
margin: 0.5rem 0 0;
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
word-break: break-all;
}
.weapon-stat-line strong {
color: var(--text);
margin-right: 0.35rem;
}
</style>