diff --git a/.project/designs/app/src/App.tsx b/.project/designs/app/src/App.tsx index 7dc2eb2e..933e490b 100644 --- a/.project/designs/app/src/App.tsx +++ b/.project/designs/app/src/App.tsx @@ -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 { diff --git a/.project/designs/app/src/components/combat/HpSlider.tsx b/.project/designs/app/src/components/combat/HpSlider.tsx new file mode 100644 index 00000000..c2d85f08 --- /dev/null +++ b/.project/designs/app/src/components/combat/HpSlider.tsx @@ -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 ( + + + onChange(Number(e.target.value))} + /> + {currentHp}/{unit.hp} + + ); +} diff --git a/.project/designs/app/src/components/combat/UnitBrowser.tsx b/.project/designs/app/src/components/combat/UnitBrowser.tsx new file mode 100644 index 00000000..f1cccf13 --- /dev/null +++ b/.project/designs/app/src/components/combat/UnitBrowser.tsx @@ -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 = { + 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("Infantry"); + + const units = UNIT_CATEGORIES[cat]; + + return ( + +
+ + {side === "attacker" ? "โฌ… Attacker" : "Defender โžก"} + +
+ + + {CATEGORIES.map(c => ( + c !== "Sea" && setCat(c)} + > + {CAT_LABELS[c]} + + ))} + + + + {units.length === 0 ? ( + No units in this category + ) : ( + units.map(u => ( + onSelect(u)} + /> + )) + )} + +
+ ); +} diff --git a/.project/designs/app/src/components/combat/UnitRow.tsx b/.project/designs/app/src/components/combat/UnitRow.tsx new file mode 100644 index 00000000..50f1a556 --- /dev/null +++ b/.project/designs/app/src/components/combat/UnitRow.tsx @@ -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 ( + + {UNIT_PORTRAIT[unit.id] ?? "โš”"} + + {unit.name} + {unit.attackType} ยท {unit.armor} + + T{unit.tier} + + {unit.attack}โš” + {unit.defense}๐Ÿ›ก + {unit.hp}โ™ฅ + + + ); +} diff --git a/.project/designs/app/src/data/allUnits.ts b/.project/designs/app/src/data/allUnits.ts new file mode 100644 index 00000000..207f89b7 --- /dev/null +++ b/.project/designs/app/src/data/allUnits.ts @@ -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 = { + fang: "pierce", + claw: "blade", + rend: "blade", + slam: "crush", + ember: "pierce", + bite: "pierce", + none: "blade", +}; + +const VALID_ATK_TYPES = new Set(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([ + "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 = { + 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; + +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( + 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 = { + 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: [], +}; diff --git a/.project/designs/app/src/pages/CombatCalculator.tsx b/.project/designs/app/src/pages/CombatCalculator.tsx new file mode 100644 index 00000000..0f24b9da --- /dev/null +++ b/.project/designs/app/src/pages/CombatCalculator.tsx @@ -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(null); + const [defender, setDefender] = useState(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 ( + + + Combat Calculator + real unit data ยท live matchup ยท damage matrix + + + + + + + + {!bothSelected ? ( + + โš” + Select a unit on each side +
+ Choose attacker (left) and defender (right) to see the matchup +
+
+ ) : ( + <> + {/* Unit summary cards */} + + + {UNIT_PORTRAIT[attacker.id] ?? "โš”"} + {attacker.name} + Tier {attacker.tier} ยท {attacker.attackType} / {attacker.armor} + + {attacker.keywords.slice(0, 4).map(kw => ( + {kw} + ))} + + + ATK {attacker.attack} + DEF {attacker.defense} + HP {attacker.hp} + MOV {attacker.movement} + + + + VS + + + {UNIT_PORTRAIT[defender.id] ?? "โš”"} + {defender.name} + Tier {defender.tier} ยท {defender.attackType} / {defender.armor} + + {defender.keywords.slice(0, 4).map(kw => ( + {kw} + ))} + + + ATK {defender.attack} + DEF {defender.defense} + HP {defender.hp} + MOV {defender.movement} + + + + + {/* HP sliders */} + + + + + + + {/* Damage matrix */} +
+ +
+ + {/* Probability */} + {result && ( + <> +
+ +
+ + {/* HP-after bars */} + + + + + + )} + + )} +
+ + + + +
+ ); +} diff --git a/.project/designs/app/src/utils/combatCalc.ts b/.project/designs/app/src/utils/combatCalc.ts new file mode 100644 index 00000000..756e4122 --- /dev/null +++ b/.project/designs/app/src/utils/combatCalc.ts @@ -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 = { + 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, + }; +} diff --git a/.project/designs/app/vite.config.ts b/.project/designs/app/vite.config.ts index 36307958..630b27d2 100644 --- a/.project/designs/app/vite.config.ts +++ b/.project/designs/app/vite.config.ts @@ -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"), + }, + }, });