feat(combat): ✨ add combat calculator components
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
2fd9eced63
commit
01278f29fc
8 changed files with 856 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
64
.project/designs/app/src/components/combat/HpSlider.tsx
Normal file
64
.project/designs/app/src/components/combat/HpSlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
.project/designs/app/src/components/combat/UnitBrowser.tsx
Normal file
130
.project/designs/app/src/components/combat/UnitBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
.project/designs/app/src/components/combat/UnitRow.tsx
Normal file
102
.project/designs/app/src/components/combat/UnitRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
.project/designs/app/src/data/allUnits.ts
Normal file
146
.project/designs/app/src/data/allUnits.ts
Normal 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: [],
|
||||
};
|
||||
341
.project/designs/app/src/pages/CombatCalculator.tsx
Normal file
341
.project/designs/app/src/pages/CombatCalculator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
.project/designs/app/src/utils/combatCalc.ts
Normal file
66
.project/designs/app/src/utils/combatCalc.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue