Wire up talent effects: stat bonuses, AC bonus, attack/damage bonuses, gear slot bonuses
This commit is contained in:
parent
859ac64868
commit
0bd83e5f92
7 changed files with 148 additions and 17 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Character, GameItem } from "../types";
|
import type { Character, GameItem } from "../types";
|
||||||
|
import { getTalentGearSlotsBonus } from "../utils/talent-effects";
|
||||||
import GearList from "./GearList";
|
import GearList from "./GearList";
|
||||||
import styles from "./GearPanel.module.css";
|
import styles from "./GearPanel.module.css";
|
||||||
|
|
||||||
|
|
@ -27,6 +28,8 @@ export default function GearPanel({
|
||||||
onCurrencyChange,
|
onCurrencyChange,
|
||||||
}: GearPanelProps) {
|
}: GearPanelProps) {
|
||||||
const slotsUsed = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
|
const slotsUsed = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
|
||||||
|
const slotsMax =
|
||||||
|
character.gear_slots_max + getTalentGearSlotsBonus(character);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
|
|
@ -36,7 +39,7 @@ export default function GearPanel({
|
||||||
sp={character.sp}
|
sp={character.sp}
|
||||||
cp={character.cp}
|
cp={character.cp}
|
||||||
slotsUsed={slotsUsed}
|
slotsUsed={slotsUsed}
|
||||||
slotsMax={character.gear_slots_max}
|
slotsMax={slotsMax}
|
||||||
onAddFromItem={(item) => onAddGearFromItem(character.id, item)}
|
onAddFromItem={(item) => onAddGearFromItem(character.id, item)}
|
||||||
onAddCustom={(data) => onAddGearCustom(character.id, data)}
|
onAddCustom={(data) => onAddGearCustom(character.id, data)}
|
||||||
onRemove={(gearId) => onRemoveGear(character.id, gearId)}
|
onRemove={(gearId) => onRemoveGear(character.id, gearId)}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bonus {
|
||||||
|
color: #4caf50;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
|
||||||
|
|
||||||
interface StatBlockProps {
|
interface StatBlockProps {
|
||||||
stats: Stat[];
|
stats: Stat[];
|
||||||
|
statBonuses?: Map<string, number>;
|
||||||
onStatChange: (statName: string, newValue: number) => void;
|
onStatChange: (statName: string, newValue: number) => void;
|
||||||
mode?: "view" | "edit";
|
mode?: "view" | "edit";
|
||||||
campaignId?: number;
|
campaignId?: number;
|
||||||
|
|
@ -17,6 +18,7 @@ interface StatBlockProps {
|
||||||
|
|
||||||
export default function StatBlock({
|
export default function StatBlock({
|
||||||
stats,
|
stats,
|
||||||
|
statBonuses,
|
||||||
onStatChange,
|
onStatChange,
|
||||||
mode = "view",
|
mode = "view",
|
||||||
campaignId,
|
campaignId,
|
||||||
|
|
@ -29,7 +31,9 @@ export default function StatBlock({
|
||||||
return (
|
return (
|
||||||
<div className={styles.statGrid}>
|
<div className={styles.statGrid}>
|
||||||
{STAT_ORDER.map((name) => {
|
{STAT_ORDER.map((name) => {
|
||||||
const value = statMap.get(name) ?? 10;
|
const baseValue = statMap.get(name) ?? 10;
|
||||||
|
const bonus = statBonuses?.get(name) ?? 0;
|
||||||
|
const value = baseValue + bonus;
|
||||||
const mod = getModifier(value);
|
const mod = getModifier(value);
|
||||||
return (
|
return (
|
||||||
<div key={name} className={styles.stat}>
|
<div key={name} className={styles.stat}>
|
||||||
|
|
@ -38,7 +42,7 @@ export default function StatBlock({
|
||||||
{mode === "edit" && (
|
{mode === "edit" && (
|
||||||
<button
|
<button
|
||||||
className={styles.btn}
|
className={styles.btn}
|
||||||
onClick={() => onStatChange(name, value - 1)}
|
onClick={() => onStatChange(name, baseValue - 1)}
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -58,13 +62,16 @@ export default function StatBlock({
|
||||||
{mode === "edit" && (
|
{mode === "edit" && (
|
||||||
<button
|
<button
|
||||||
className={styles.btn}
|
className={styles.btn}
|
||||||
onClick={() => onStatChange(name, value + 1)}
|
onClick={() => onStatChange(name, baseValue + 1)}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.score}>{value}</span>
|
<span className={styles.score}>
|
||||||
|
{value}
|
||||||
|
{bonus > 0 && <span className={styles.bonus}> (+{bonus})</span>}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import StatBlock from "./StatBlock";
|
||||||
import AttackBlock from "./AttackBlock";
|
import AttackBlock from "./AttackBlock";
|
||||||
import styles from "./StatsPanel.module.css";
|
import styles from "./StatsPanel.module.css";
|
||||||
|
|
||||||
|
const STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
|
||||||
|
|
||||||
interface StatsPanelProps {
|
interface StatsPanelProps {
|
||||||
character: Character;
|
character: Character;
|
||||||
mode: "view" | "edit";
|
mode: "view" | "edit";
|
||||||
|
|
@ -21,11 +23,24 @@ export default function StatsPanel({
|
||||||
}: StatsPanelProps) {
|
}: StatsPanelProps) {
|
||||||
const attacks = generateAttacks(character);
|
const attacks = generateAttacks(character);
|
||||||
|
|
||||||
|
// Compute talent stat bonuses
|
||||||
|
const statBonuses = new Map<string, number>();
|
||||||
|
for (const stat of STATS) {
|
||||||
|
let bonus = 0;
|
||||||
|
for (const talent of character.talents) {
|
||||||
|
if (talent.effect.stat_bonus === stat) {
|
||||||
|
bonus += (talent.effect.stat_bonus_value as number) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bonus > 0) statBonuses.set(stat, bonus);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<div className={styles.sectionTitle}>Ability Scores</div>
|
<div className={styles.sectionTitle}>Ability Scores</div>
|
||||||
<StatBlock
|
<StatBlock
|
||||||
stats={character.stats}
|
stats={character.stats}
|
||||||
|
statBonuses={statBonuses.size > 0 ? statBonuses : undefined}
|
||||||
onStatChange={(statName, value) =>
|
onStatChange={(statName, value) =>
|
||||||
onStatChange(character.id, statName, value)
|
onStatChange(character.id, statName, value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Character } from "../types";
|
import type { Character } from "../types";
|
||||||
import { getModifier } from "./modifiers";
|
import { getModifier } from "./modifiers";
|
||||||
|
import { getEffectiveStat, getTalentAcBonus } from "./talent-effects";
|
||||||
|
|
||||||
export interface AcBreakdown {
|
export interface AcBreakdown {
|
||||||
calculated: number;
|
calculated: number;
|
||||||
|
|
@ -9,9 +10,7 @@ export interface AcBreakdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateAC(character: Character): AcBreakdown {
|
export function calculateAC(character: Character): AcBreakdown {
|
||||||
const dexMod = getModifier(
|
const dexMod = getModifier(getEffectiveStat(character, "DEX"));
|
||||||
character.stats.find((s) => s.stat_name === "DEX")?.value ?? 10,
|
|
||||||
);
|
|
||||||
|
|
||||||
let base = 10 + dexMod;
|
let base = 10 + dexMod;
|
||||||
let source = "Unarmored";
|
let source = "Unarmored";
|
||||||
|
|
@ -36,6 +35,13 @@ export function calculateAC(character: Character): AcBreakdown {
|
||||||
source += " + " + shield.name;
|
source += " + " + shield.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Talent AC bonuses (e.g. Shield of Faith)
|
||||||
|
const talentAcBonus = getTalentAcBonus(character);
|
||||||
|
if (talentAcBonus > 0) {
|
||||||
|
base += talentAcBonus;
|
||||||
|
source += " + talents";
|
||||||
|
}
|
||||||
|
|
||||||
const override = (character.overrides?.ac as number | undefined) ?? null;
|
const override = (character.overrides?.ac as number | undefined) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import type { Character, AttackLine } from "../types";
|
import type { Character, AttackLine } from "../types";
|
||||||
import { getModifier, formatModifier } from "./modifiers";
|
import { getModifier, formatModifier } from "./modifiers";
|
||||||
|
import {
|
||||||
|
getEffectiveStat,
|
||||||
|
getTalentAttackBonus,
|
||||||
|
getTalentDamageBonus,
|
||||||
|
} from "./talent-effects";
|
||||||
|
|
||||||
export function generateAttacks(character: Character): AttackLine[] {
|
export function generateAttacks(character: Character): AttackLine[] {
|
||||||
const strMod = getModifier(
|
const strMod = getModifier(getEffectiveStat(character, "STR"));
|
||||||
character.stats.find((s) => s.stat_name === "STR")?.value ?? 10,
|
const dexMod = getModifier(getEffectiveStat(character, "DEX"));
|
||||||
);
|
const talentAttackBonus = getTalentAttackBonus(character);
|
||||||
const dexMod = getModifier(
|
const talentDamageBonus = getTalentDamageBonus(character);
|
||||||
character.stats.find((s) => s.stat_name === "DEX")?.value ?? 10,
|
|
||||||
);
|
|
||||||
|
|
||||||
const attacks: AttackLine[] = [];
|
const attacks: AttackLine[] = [];
|
||||||
|
|
||||||
|
|
@ -15,7 +18,7 @@ export function generateAttacks(character: Character): AttackLine[] {
|
||||||
if (gear.type !== "weapon") continue;
|
if (gear.type !== "weapon") continue;
|
||||||
|
|
||||||
const effects = gear.effects;
|
const effects = gear.effects;
|
||||||
const damage = (effects.damage as string) || "1d4";
|
let damage = (effects.damage as string) || "1d4";
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
|
|
||||||
let mod: number;
|
let mod: number;
|
||||||
|
|
@ -28,8 +31,14 @@ export function generateAttacks(character: Character): AttackLine[] {
|
||||||
mod = strMod;
|
mod = strMod;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bonus = (effects.bonus as number) || 0;
|
// Gear-specific bonus
|
||||||
mod += bonus;
|
const gearBonus = (effects.bonus as number) || 0;
|
||||||
|
mod += gearBonus + talentAttackBonus;
|
||||||
|
|
||||||
|
// Talent damage bonus — append to damage expression
|
||||||
|
if (talentDamageBonus > 0) {
|
||||||
|
damage = `${damage}+${talentDamageBonus}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (effects.two_handed) tags.push("2H");
|
if (effects.two_handed) tags.push("2H");
|
||||||
if (effects.thrown) tags.push("T");
|
if (effects.thrown) tags.push("T");
|
||||||
|
|
|
||||||
86
client/src/utils/talent-effects.ts
Normal file
86
client/src/utils/talent-effects.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type { Character } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective stat value after talent bonuses are applied.
|
||||||
|
* e.g. if character has base STR 14 and a talent with { stat_bonus: 'STR', stat_bonus_value: 2 },
|
||||||
|
* this returns 16.
|
||||||
|
*/
|
||||||
|
export function getEffectiveStat(
|
||||||
|
character: Character,
|
||||||
|
statName: string,
|
||||||
|
): number {
|
||||||
|
const base =
|
||||||
|
character.stats.find((s) => s.stat_name === statName)?.value ?? 10;
|
||||||
|
let bonus = 0;
|
||||||
|
for (const talent of character.talents) {
|
||||||
|
if (talent.effect.stat_bonus === statName) {
|
||||||
|
bonus += (talent.effect.stat_bonus_value as number) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base + bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total AC bonus from talents (e.g. Shield of Faith +1 AC).
|
||||||
|
*/
|
||||||
|
export function getTalentAcBonus(character: Character): number {
|
||||||
|
let bonus = 0;
|
||||||
|
for (const talent of character.talents) {
|
||||||
|
if (typeof talent.effect.ac_bonus === "number") {
|
||||||
|
bonus += talent.effect.ac_bonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total attack bonus from talents (e.g. Weapon Mastery +1).
|
||||||
|
*/
|
||||||
|
export function getTalentAttackBonus(character: Character): number {
|
||||||
|
let bonus = 0;
|
||||||
|
for (const talent of character.talents) {
|
||||||
|
if (typeof talent.effect.attack_bonus === "number") {
|
||||||
|
bonus += talent.effect.attack_bonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total damage bonus from talents (e.g. Weapon Mastery +1).
|
||||||
|
*/
|
||||||
|
export function getTalentDamageBonus(character: Character): number {
|
||||||
|
let bonus = 0;
|
||||||
|
for (const talent of character.talents) {
|
||||||
|
if (typeof talent.effect.damage_bonus === "number") {
|
||||||
|
bonus += talent.effect.damage_bonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get extra gear slots from talents (e.g. Hauler +2).
|
||||||
|
*/
|
||||||
|
export function getTalentGearSlotsBonus(character: Character): number {
|
||||||
|
let bonus = 0;
|
||||||
|
for (const talent of character.talents) {
|
||||||
|
if (typeof talent.effect.gear_slots_bonus === "number") {
|
||||||
|
bonus += talent.effect.gear_slots_bonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get HP bonus from talents (e.g. Grit +2).
|
||||||
|
*/
|
||||||
|
export function getTalentHpBonus(character: Character): number {
|
||||||
|
let bonus = 0;
|
||||||
|
for (const talent of character.talents) {
|
||||||
|
if (typeof talent.effect.hp_bonus === "number") {
|
||||||
|
bonus += talent.effect.hp_bonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue