feat(combat): add combat calculator components

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-26 16:48:59 -07:00
parent 2fd9eced63
commit 01278f29fc
8 changed files with 856 additions and 0 deletions

View file

@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { IndexPage } from "./pages/Index";
import { CombatPreviewPage } from "./pages/CombatPreview";
import { CombatCalculatorPage } from "./pages/CombatCalculator";
// Remaining sketch pages are stubs pending port from HTML
function Stub({ name }: { name: string }): React.ReactElement {

View file

@ -0,0 +1,64 @@
import styled from "styled-components";
import { t } from "../../theme";
import type { Unit } from "../../data/units";
interface Props {
unit: Unit;
currentHp: number;
onChange: (hp: number) => void;
}
const Wrap = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(10,8,16,0.5);
border: 1px solid ${t.border.divider};
border-radius: 3px;
`;
const Label = styled.span`
font-size: 11px;
color: ${t.text.muted};
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
`;
const Slider = styled.input`
flex: 1;
accent-color: ${t.accent.sage};
cursor: pointer;
height: 4px;
`;
const HpDisplay = styled.span<{ $pct: number }>`
font-family: ${t.font.mono};
font-size: 13px;
font-weight: bold;
color: ${({ $pct }) =>
$pct > 0.6 ? t.accent.sage :
$pct > 0.3 ? t.sem.warning :
t.sem.negative};
width: 48px;
text-align: right;
flex-shrink: 0;
`;
export function HpSlider({ unit, currentHp, onChange }: Props): React.ReactElement {
const pct = currentHp / unit.hp;
return (
<Wrap>
<Label>HP</Label>
<Slider
type="range"
min={1}
max={unit.hp}
value={currentHp}
onChange={e => onChange(Number(e.target.value))}
/>
<HpDisplay $pct={pct}>{currentHp}/{unit.hp}</HpDisplay>
</Wrap>
);
}

View file

@ -0,0 +1,130 @@
import { useState } from "react";
import styled from "styled-components";
import { t } from "../../theme";
import type { Unit } from "../../data/units";
import { UNIT_CATEGORIES, type UnitCategory } from "../../data/allUnits";
import { UnitRow } from "./UnitRow";
interface Props {
side: "attacker" | "defender";
selected: Unit | null;
onSelect: (u: Unit) => void;
}
const CATEGORIES: UnitCategory[] = ["Infantry", "Beast", "Air", "Sea"];
const CAT_LABELS: Record<UnitCategory, string> = {
Infantry: "⚔ Infantry",
Beast: "🐉 Beast/Mech",
Air: "🦅 Air",
Sea: "⚓ Sea",
};
const Wrap = styled.div`
display: flex;
flex-direction: column;
background: ${t.bg.panel};
border: 1px solid ${t.border.panel};
border-radius: 4px;
overflow: hidden;
height: 100%;
`;
const Header = styled.div`
background: #1a1228;
border-bottom: 1px solid ${t.border.panel};
padding: 10px 14px 8px;
`;
const HeaderTitle = styled.div`
font-family: ${t.font.heading};
font-size: 14px;
color: ${t.text.title};
letter-spacing: 0.05em;
`;
const CatTabs = styled.div`
display: flex;
border-bottom: 1px solid ${t.border.panel};
background: rgba(10,8,16,0.6);
`;
const CatTab = styled.button<{ $active: boolean; $disabled: boolean }>`
flex: 1;
padding: 7px 4px;
font-size: 10px;
font-weight: bold;
font-family: ${t.font.body};
letter-spacing: 0.03em;
color: ${({ $active, $disabled }) =>
$disabled ? "#555" : $active ? t.text.title : t.text.muted};
background: transparent;
border: none;
border-bottom: 2px solid ${({ $active }) => ($active ? t.accent.gold : "transparent")};
margin-bottom: -1px;
cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")};
transition: color 120ms ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const UnitList = styled.div`
flex: 1;
overflow-y: auto;
padding: 8px;
scrollbar-width: thin;
scrollbar-color: ${t.border.panel} transparent;
`;
const EmptyNote = styled.div`
font-size: 12px;
color: ${t.text.muted};
text-align: center;
padding: 24px 8px;
font-style: italic;
`;
export function UnitBrowser({ side, selected, onSelect }: Props): React.ReactElement {
const [cat, setCat] = useState<UnitCategory>("Infantry");
const units = UNIT_CATEGORIES[cat];
return (
<Wrap>
<Header>
<HeaderTitle>
{side === "attacker" ? "⬅ Attacker" : "Defender ➡"}
</HeaderTitle>
</Header>
<CatTabs>
{CATEGORIES.map(c => (
<CatTab
key={c}
$active={c === cat}
$disabled={c === "Sea"}
onClick={() => c !== "Sea" && setCat(c)}
>
{CAT_LABELS[c]}
</CatTab>
))}
</CatTabs>
<UnitList>
{units.length === 0 ? (
<EmptyNote>No units in this category</EmptyNote>
) : (
units.map(u => (
<UnitRow
key={u.id}
unit={u}
selected={selected?.id === u.id}
onClick={() => onSelect(u)}
/>
))
)}
</UnitList>
</Wrap>
);
}

View file

@ -0,0 +1,102 @@
import styled from "styled-components";
import { t } from "../../theme";
import type { Unit } from "../../data/units";
import { UNIT_PORTRAIT } from "../../data/allUnits";
interface Props {
unit: Unit;
selected: boolean;
onClick: () => void;
}
const Row = styled.button<{ $selected: boolean }>`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
background: ${({ $selected }) => ($selected ? t.bg.listSel : "transparent")};
border: 1px solid ${({ $selected }) => ($selected ? t.accent.gold : "transparent")};
border-radius: 3px;
cursor: pointer;
text-align: left;
transition: all 120ms ease;
margin-bottom: 3px;
&:hover {
background: ${({ $selected }) => ($selected ? t.bg.listSel : t.bg.raised)};
border-color: ${({ $selected }) => ($selected ? t.accent.gold : t.border.panel)};
}
`;
const Portrait = styled.span`
font-size: 18px;
width: 24px;
text-align: center;
flex-shrink: 0;
`;
const NameBlock = styled.div`
flex: 1;
min-width: 0;
`;
const Name = styled.div`
font-size: 13px;
color: ${t.text.primary};
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Meta = styled.div`
font-size: 10px;
color: ${t.text.muted};
font-family: ${t.font.mono};
`;
const StatRow = styled.div`
display: flex;
gap: 4px;
flex-shrink: 0;
`;
const StatChip = styled.span<{ $color: string }>`
font-size: 10px;
font-family: ${t.font.mono};
font-weight: bold;
color: ${({ $color }) => $color};
background: rgba(0,0,0,0.3);
border-radius: 2px;
padding: 1px 4px;
`;
const TierBadge = styled.span`
font-size: 9px;
color: ${t.accent.gold};
font-family: ${t.font.mono};
background: #2a1a06;
border: 1px solid #73591f66;
border-radius: 2px;
padding: 1px 4px;
flex-shrink: 0;
`;
export function UnitRow({ unit, selected, onClick }: Props): React.ReactElement {
return (
<Row $selected={selected} onClick={onClick}>
<Portrait>{UNIT_PORTRAIT[unit.id] ?? "⚔"}</Portrait>
<NameBlock>
<Name>{unit.name}</Name>
<Meta>{unit.attackType} · {unit.armor}</Meta>
</NameBlock>
<TierBadge>T{unit.tier}</TierBadge>
<StatRow>
<StatChip $color={t.sem.negative}>{unit.attack}</StatChip>
<StatChip $color={t.accent.science}>{unit.defense}🛡</StatChip>
<StatChip $color={t.accent.sage}>{unit.hp}</StatChip>
</StatRow>
</Row>
);
}

View file

@ -0,0 +1,146 @@
import type { Unit, AttackType, ArmorType } from "./units";
import { DAMAGE_MATRIX } from "./units";
// ── raw JSON shape (snake_case from the unit files) ─────────────────────────
interface RawUnit {
id: string;
name: string;
unit_type: string;
domain?: string;
keywords?: string[];
attributes?: string[];
attack_type?: string;
hp?: number;
attack?: number;
defense?: number;
ranged_attack?: number;
range?: number;
movement?: number;
tier?: number;
}
// non-combatants and the empty stub file excluded from the calculator
const SKIP_IDS = new Set([
"worker", "founder", "dwarf_tribe", "dwarf_wanderer", "stub",
]);
// ── attack-type normalisation for wild creatures ─────────────────────────────
const WILD_ATK_MAP: Record<string, AttackType> = {
fang: "pierce",
claw: "blade",
rend: "blade",
slam: "crush",
ember: "pierce",
bite: "pierce",
none: "blade",
};
const VALID_ATK_TYPES = new Set<string>(Object.keys(DAMAGE_MATRIX));
function normaliseAtk(raw: string | undefined): AttackType {
if (!raw) return "blade";
if (VALID_ATK_TYPES.has(raw)) return raw as AttackType;
return WILD_ATK_MAP[raw] ?? "blade";
}
// ── armor extraction from attributes array ───────────────────────────────────
const ARMOR_VALUES = new Set<string>([
"unarmored", "light", "medium", "armored", "heavy", "plate", "fortified",
]);
function extractArmor(attrs: string[] | undefined): ArmorType {
const match = attrs?.find(a => ARMOR_VALUES.has(a));
return (match as ArmorType | undefined) ?? "unarmored";
}
// ── emoji portrait map ───────────────────────────────────────────────────────
export const UNIT_PORTRAIT: Record<string, string> = {
warrior: "⚔",
berserker: "🪓",
ironwarden: "🪖",
spearmen: "🗡",
pikeman: "🛡",
cavalry: "🐎",
archer: "🏹",
runesmith: "🔩",
forge_titan: "🤖",
mithril_vanguard: "⚜",
feral_spider: "🕷",
shambling_dead: "💀",
stone_sentinel: "🗿",
garden_snail: "🐌",
wolf_pack: "🐺",
fire_imp: "🔥",
dire_wolf: "🐺",
frostfang_alpha: "❄️",
dire_bear: "🐻",
basilisk_wild: "🦎",
lava_elemental: "🌋",
ancient_hydra: "🐍",
wild_wyvern: "🦅",
drake_wild: "🐲",
elder_wyrm: "🐉",
};
// ── glob load all unit JSONs ─────────────────────────────────────────────────
// Path from src/data/ → repo root (5 levels up) → public/games/.../units/
const rawModules = import.meta.glob(
"../../../../../public/games/age-of-dwarves/data/units/*.json",
{ eager: true }
) as Record<string, { default: RawUnit[] }>;
function normalise(raw: RawUnit): Unit {
return {
id: raw.id,
name: raw.name,
tier: raw.tier ?? 1,
archetype: raw.unit_type === "military" ? "Military" : "Wild",
attackType: normaliseAtk(raw.attack_type),
armor: extractArmor(raw.attributes),
hp: raw.hp ?? 1,
attack: raw.attack ?? 0,
defense: raw.defense ?? 0,
rangedAttack: raw.ranged_attack ?? 0,
range: raw.range ?? 0,
movement: raw.movement ?? 1,
keywords: raw.keywords ?? [],
unique: false,
};
}
const rawList: RawUnit[] = Object.values(rawModules)
.flatMap(mod => (Array.isArray(mod.default) ? mod.default : [mod.default]))
.filter(raw => raw?.id && !SKIP_IDS.has(raw.id) && raw.unit_type !== "support" && raw.unit_type !== "npc");
export const ALL_UNITS: Unit[] = rawList
.map(normalise)
.sort((a, b) => a.tier - b.tier || a.name.localeCompare(b.name));
// ── categorisation ───────────────────────────────────────────────────────────
export type UnitCategory = "Infantry" | "Beast" | "Air" | "Sea";
// keep a domain lookup from raw data for categorisation
const DOMAIN_MAP = new Map<string, string>(
rawList.map(r => [r.id, r.domain ?? "land"])
);
function categorise(u: Unit): UnitCategory {
const domain = DOMAIN_MAP.get(u.id) ?? "land";
if (domain === "air") return "Air";
if (domain === "sea") return "Sea";
if (u.archetype === "Military") return "Infantry";
return "Beast";
}
export const UNIT_CATEGORIES: Record<UnitCategory, Unit[]> = {
Infantry: ALL_UNITS.filter(u => categorise(u) === "Infantry"),
Beast: ALL_UNITS.filter(u => categorise(u) === "Beast"),
Air: ALL_UNITS.filter(u => categorise(u) === "Air"),
Sea: [],
};

View file

@ -0,0 +1,341 @@
import { useState } from "react";
import styled from "styled-components";
import { t } from "../theme";
import type { Unit } from "../data/units";
import { UNIT_PORTRAIT } from "../data/allUnits";
import { calcCombat } from "../utils/combatCalc";
import { UnitBrowser } from "../components/combat/UnitBrowser";
import { HpSlider } from "../components/combat/HpSlider";
import { DamageMatrix } from "../components/combat/DamageMatrix";
import { ProbabilityBar } from "../components/combat/ProbabilityBar";
import { HpAfterBar } from "../components/combat/HpAfterBar";
import { Tag, TagRow } from "../components/ui/Tag";
// ── layout ───────────────────────────────────────────────────────────────────
const Shell = styled.div`
display: grid;
grid-template-columns: 280px 1fr 280px;
grid-template-rows: 44px 1fr;
height: 100vh;
overflow: hidden;
`;
const TopBar = styled.div`
grid-column: 1 / -1;
background: ${t.bg.panel};
border-bottom: 1px solid ${t.border.panel};
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
`;
const TopTitle = styled.div`
font-family: ${t.font.heading};
font-size: 20px;
color: ${t.text.title};
letter-spacing: 0.04em;
`;
const TopSub = styled.div`
font-size: 12px;
color: ${t.text.muted};
font-family: ${t.font.mono};
`;
const LeftPanel = styled.div`
border-right: 1px solid ${t.border.panel};
overflow: hidden;
display: flex;
flex-direction: column;
`;
const RightPanel = styled.div`
border-left: 1px solid ${t.border.panel};
overflow: hidden;
display: flex;
flex-direction: column;
`;
const CenterPanel = styled.div`
overflow-y: auto;
padding: 16px;
scrollbar-width: thin;
scrollbar-color: ${t.border.panel} transparent;
`;
// ── empty state ───────────────────────────────────────────────────────────────
const EmptyState = styled.div`
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: ${t.text.muted};
`;
const EmptyIcon = styled.div`
font-size: 48px;
opacity: 0.4;
`;
const EmptyText = styled.div`
font-family: ${t.font.heading};
font-size: 20px;
color: ${t.text.secondary};
letter-spacing: 0.04em;
`;
// ── unit summary card (top of center) ────────────────────────────────────────
const UnitSummaryRow = styled.div`
display: grid;
grid-template-columns: 1fr 40px 1fr;
gap: 0;
margin-bottom: 12px;
align-items: start;
`;
const UnitCard = styled.div<{ $side: "attacker" | "defender" }>`
background: ${t.bg.panel};
border: 1px solid ${t.border.panel};
border-radius: 4px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
align-items: ${({ $side }) => ($side === "defender" ? "flex-end" : "flex-start")};
`;
const Portrait = styled.div<{ $color: string }>`
font-size: 32px;
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid ${({ $color }) => $color};
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at 40% 40%, #2a1a06, #0e0a17);
`;
const UnitName = styled.div`
font-family: ${t.font.heading};
font-size: 16px;
color: ${t.text.title};
letter-spacing: 0.04em;
`;
const UnitMeta = styled.div`
font-size: 11px;
color: ${t.text.muted};
`;
const VsColumn = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding-top: 12px;
`;
const VsBadge = styled.div`
font-family: ${t.font.heading};
font-size: 18px;
color: ${t.sem.negative};
width: 36px;
height: 36px;
border: 1px solid #d9594044;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
`;
// ── sliders row ───────────────────────────────────────────────────────────────
const SlidersRow = styled.div`
display: grid;
grid-template-columns: 1fr 40px 1fr;
gap: 0;
margin-bottom: 12px;
align-items: center;
`;
const SliderGap = styled.div``; // spacer for the VS column
// ── section ───────────────────────────────────────────────────────────────────
const Section = styled.div`
margin-bottom: 12px;
`;
// ── hp after row ──────────────────────────────────────────────────────────────
const HpAfterRow = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
`;
// ── stat row in unit card ─────────────────────────────────────────────────────
const StatLine = styled.div`
display: flex;
gap: 6px;
font-size: 12px;
font-family: ${t.font.mono};
`;
// ── component ─────────────────────────────────────────────────────────────────
function unitColor(unit: Unit): string {
return unit.archetype === "Military" ? "#6699ff" : "#e69933";
}
function statColor(key: "atk" | "def" | "hp"): string {
return key === "atk" ? t.sem.negative : key === "def" ? t.accent.science : t.accent.sage;
}
export function CombatCalculatorPage(): React.ReactElement {
const [attacker, setAttacker] = useState<Unit | null>(null);
const [defender, setDefender] = useState<Unit | null>(null);
const [atkHp, setAtkHp] = useState(0);
const [defHp, setDefHp] = useState(0);
function handleSelectAttacker(u: Unit): void {
setAttacker(u);
setAtkHp(u.hp);
}
function handleSelectDefender(u: Unit): void {
setDefender(u);
setDefHp(u.hp);
}
const result = attacker && defender
? calcCombat(attacker, atkHp, defender, defHp)
: null;
const bothSelected = attacker !== null && defender !== null;
return (
<Shell>
<TopBar>
<TopTitle>Combat Calculator</TopTitle>
<TopSub>real unit data · live matchup · damage matrix</TopSub>
</TopBar>
<LeftPanel>
<UnitBrowser side="attacker" selected={attacker} onSelect={handleSelectAttacker} />
</LeftPanel>
<CenterPanel>
{!bothSelected ? (
<EmptyState>
<EmptyIcon></EmptyIcon>
<EmptyText>Select a unit on each side</EmptyText>
<div style={{ fontSize: 13, color: t.text.muted }}>
Choose attacker (left) and defender (right) to see the matchup
</div>
</EmptyState>
) : (
<>
{/* Unit summary cards */}
<UnitSummaryRow>
<UnitCard $side="attacker">
<Portrait $color={unitColor(attacker)}>{UNIT_PORTRAIT[attacker.id] ?? "⚔"}</Portrait>
<UnitName>{attacker.name}</UnitName>
<UnitMeta>Tier {attacker.tier} · {attacker.attackType} / {attacker.armor}</UnitMeta>
<TagRow>
{attacker.keywords.slice(0, 4).map(kw => (
<Tag key={kw} variant="kw">{kw}</Tag>
))}
</TagRow>
<StatLine>
<span style={{ color: statColor("atk") }}>ATK {attacker.attack}</span>
<span style={{ color: statColor("def") }}>DEF {attacker.defense}</span>
<span style={{ color: statColor("hp") }}>HP {attacker.hp}</span>
<span style={{ color: t.accent.gold }}>MOV {attacker.movement}</span>
</StatLine>
</UnitCard>
<VsColumn><VsBadge>VS</VsBadge></VsColumn>
<UnitCard $side="defender">
<Portrait $color={unitColor(defender)}>{UNIT_PORTRAIT[defender.id] ?? "⚔"}</Portrait>
<UnitName>{defender.name}</UnitName>
<UnitMeta>Tier {defender.tier} · {defender.attackType} / {defender.armor}</UnitMeta>
<TagRow $align="end">
{defender.keywords.slice(0, 4).map(kw => (
<Tag key={kw} variant="kw">{kw}</Tag>
))}
</TagRow>
<StatLine>
<span style={{ color: statColor("atk") }}>ATK {defender.attack}</span>
<span style={{ color: statColor("def") }}>DEF {defender.defense}</span>
<span style={{ color: statColor("hp") }}>HP {defender.hp}</span>
<span style={{ color: t.accent.gold }}>MOV {defender.movement}</span>
</StatLine>
</UnitCard>
</UnitSummaryRow>
{/* HP sliders */}
<SlidersRow>
<HpSlider unit={attacker} currentHp={atkHp} onChange={setAtkHp} />
<SliderGap />
<HpSlider unit={defender} currentHp={defHp} onChange={setDefHp} />
</SlidersRow>
{/* Damage matrix */}
<Section>
<DamageMatrix
highlightAtk={attacker.attackType}
highlightArmor={defender.armor}
/>
</Section>
{/* Probability */}
{result && (
<>
<Section>
<ProbabilityBar
effAtk={result.effAtk}
effDef={result.effDef}
atkLabel={`${attacker.name} wins`}
defLabel={`${defender.name} survives`}
/>
</Section>
{/* HP-after bars */}
<HpAfterRow>
<HpAfterBar
label={`${attacker.name} HP after (${atkHp} now)`}
currentHp={atkHp}
maxHp={attacker.hp}
dmgMin={result.dmgToAttacker.min}
dmgMax={result.dmgToAttacker.max}
/>
<HpAfterBar
label={`${defender.name} HP after (${defHp} now)`}
currentHp={defHp}
maxHp={defender.hp}
dmgMin={result.dmgToDefender.min}
dmgMax={result.dmgToDefender.max}
/>
</HpAfterRow>
</>
)}
</>
)}
</CenterPanel>
<RightPanel>
<UnitBrowser side="defender" selected={defender} onSelect={handleSelectDefender} />
</RightPanel>
</Shell>
);
}

View file

@ -0,0 +1,66 @@
import type { Unit, AttackType } from "../data/units";
import { DAMAGE_MATRIX } from "../data/units";
export interface DamageRange {
min: number;
avg: number;
max: number;
}
export interface CombatResult {
effAtk: number;
effDef: number;
matrixMult: number;
counterMatrixMult: number;
dmgToDefender: DamageRange;
dmgToAttacker: DamageRange;
winPct: number;
}
const WILD_ATK_MAP: Record<string, AttackType> = {
fang: "pierce", claw: "blade", rend: "blade",
slam: "crush", ember: "pierce", bite: "pierce",
};
function resolveAtk(t: string): AttackType {
if (t in DAMAGE_MATRIX) return t as AttackType;
return WILD_ATK_MAP[t] ?? "blade";
}
function damageRange(eff: number): DamageRange {
return {
min: Math.max(1, Math.round(eff * 0.8)),
avg: Math.max(1, Math.round(eff)),
max: Math.max(1, Math.round(eff * 1.2)),
};
}
export function calcCombat(
atk: Unit, _atkHp: number,
def: Unit, _defHp: number
): CombatResult {
const atkType = resolveAtk(atk.attackType);
const defType = resolveAtk(def.attackType);
const matrixMult = DAMAGE_MATRIX[atkType]?.[def.armor] ?? 1.0;
const counterMatrixMult = DAMAGE_MATRIX[defType]?.[atk.armor] ?? 1.0;
const effAtk = atk.attack * matrixMult;
const effDef = def.defense;
const counterEff = def.attack * counterMatrixMult;
const winPct = effAtk + effDef > 0
? effAtk / (effAtk + effDef)
: 0.5;
return {
effAtk,
effDef,
matrixMult,
counterMatrixMult,
dmgToDefender: damageRange(effAtk),
dmgToAttacker: damageRange(counterEff),
winPct,
};
}

View file

@ -1,7 +1,13 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 7777 },
resolve: {
alias: {
"@game-data": path.resolve(__dirname, "../../../public/games/age-of-dwarves/data"),
},
},
});