feat(@projects/@magic-civilization): ✨ add combat system components
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8163fb8fcb
commit
2fd9eced63
18 changed files with 1462 additions and 2 deletions
34
.project/designs/app/src/App.tsx
Normal file
34
.project/designs/app/src/App.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { IndexPage } from "./pages/Index";
|
||||
import { CombatPreviewPage } from "./pages/CombatPreview";
|
||||
|
||||
// Remaining sketch pages are stubs pending port from HTML
|
||||
function Stub({ name }: { name: string }): React.ReactElement {
|
||||
return (
|
||||
<div style={{ padding: 48, fontFamily: "'Bitter', serif", color: "#e0d8c8", textAlign: "center" }}>
|
||||
<div style={{ fontFamily: "'Grenze Gotisch', serif", fontSize: 32, color: "#f2d973", marginBottom: 12 }}>
|
||||
{name}
|
||||
</div>
|
||||
<div style={{ color: "#b2b2b2", fontSize: 14 }}>
|
||||
Pending port from HTML sketch → React components
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<IndexPage />} />
|
||||
<Route path="/combat" element={<CombatPreviewPage />} />
|
||||
<Route path="/gallery" element={<Stub name="Design Gallery" />} />
|
||||
<Route path="/hud" element={<Stub name="World Map HUD" />} />
|
||||
<Route path="/city" element={<Stub name="City Screen" />} />
|
||||
<Route path="/menu" element={<Stub name="Main Menu" />} />
|
||||
<Route path="/tech" element={<Stub name="Tech Tree" />} />
|
||||
<Route path="/promotion" element={<Stub name="Promotion Picker" />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
190
.project/designs/app/src/components/combat/CombatantCard.tsx
Normal file
190
.project/designs/app/src/components/combat/CombatantCard.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
import { Tag, TagRow } from "../ui/Tag";
|
||||
import { ModifierList } from "./ModifierList";
|
||||
import type { CombatantState } from "../../data/scenarios";
|
||||
|
||||
interface Props {
|
||||
state: CombatantState;
|
||||
side: "attacker" | "defender";
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const UnitHeader = styled.div<{ $side: "attacker" | "defender" }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-direction: ${({ $side }) => ($side === "defender" ? "row-reverse" : "row")};
|
||||
`;
|
||||
|
||||
const Portrait = styled.div<{ $color: string }>`
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${({ $color }) => $color};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
background: radial-gradient(ellipse at 40% 40%, #2a1a06, #0e0a17);
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const InfoBlock = styled.div<{ $side: "attacker" | "defender" }>`
|
||||
flex: 1;
|
||||
text-align: ${({ $side }) => ($side === "defender" ? "right" : "left")};
|
||||
`;
|
||||
|
||||
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};
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const StatGrid = styled.div<{ $side: "attacker" | "defender" }>`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
margin-bottom: 10px;
|
||||
text-align: ${({ $side }) => ($side === "defender" ? "right" : "left")};
|
||||
`;
|
||||
|
||||
const StatRow = styled.div<{ $side: "attacker" | "defender" }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
flex-direction: ${({ $side }) => ($side === "defender" ? "row-reverse" : "row")};
|
||||
`;
|
||||
|
||||
const StatLabel = styled.span`
|
||||
font-size: 11px;
|
||||
color: ${t.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const ItemChip = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #0a180a;
|
||||
border: 1px solid #66b86655;
|
||||
border-radius: 2px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
color: ${t.accent.sage};
|
||||
margin-right: 4px;
|
||||
margin-bottom: 3px;
|
||||
`;
|
||||
|
||||
const ItemsLabel = styled.div<{ $side: "attacker" | "defender" }>`
|
||||
font-size: 10px;
|
||||
color: ${t.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 4px;
|
||||
text-align: ${({ $side }) => ($side === "defender" ? "right" : "left")};
|
||||
`;
|
||||
|
||||
const portraits: Record<string, string> = {
|
||||
warrior: "⚔", berserker: "🪓", ironwarden: "🪖",
|
||||
pikeman: "🛡", spearmen: "🗡", cavalry: "🐎",
|
||||
archer: "🏹", runesmith: "🔩",
|
||||
};
|
||||
|
||||
const statColors = {
|
||||
attack: t.sem.negative, defense: t.accent.science,
|
||||
hp: t.accent.sage, movement: t.accent.gold,
|
||||
};
|
||||
|
||||
function kwVariant(kw: string, unit_keywords: string[]): "kwActive" | "kwDanger" | "kw" {
|
||||
const danger = ["rage", "anti_cavalry", "first_strike", "trample"];
|
||||
const active = ["formation", "shield_wall", "zoc", "fast", "flanking", "ranged", "reach"];
|
||||
if (danger.includes(kw)) return "kwDanger";
|
||||
if (active.includes(kw)) return "kwActive";
|
||||
return "kw";
|
||||
void unit_keywords;
|
||||
}
|
||||
|
||||
export function CombatantCard({ state, side }: Props): React.ReactElement {
|
||||
const { unit, currentHp, clan, clanColor, promotions, items, modifiers } = state;
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<UnitHeader $side={side}>
|
||||
<Portrait $color={clanColor}>{portraits[unit.id] ?? "⚔"}</Portrait>
|
||||
<InfoBlock $side={side}>
|
||||
<UnitName>{unit.name}</UnitName>
|
||||
<UnitMeta>Tier {unit.tier} · {unit.archetype} · {unit.id}.json</UnitMeta>
|
||||
<div style={{ fontSize: 11, color: clanColor, marginTop: 1 }}>{clan}</div>
|
||||
</InfoBlock>
|
||||
</UnitHeader>
|
||||
|
||||
<TagRow $align={side === "defender" ? "end" : undefined}>
|
||||
<Tag variant="atk">{unit.attackType}</Tag>
|
||||
<Tag variant="arm">{unit.armor}</Tag>
|
||||
{unit.keywords.map(kw => (
|
||||
<Tag key={kw} variant={kwVariant(kw, unit.keywords)}>{kw}</Tag>
|
||||
))}
|
||||
{promotions.map(p => (
|
||||
<Tag key={p} variant="kw">{p} ✓</Tag>
|
||||
))}
|
||||
</TagRow>
|
||||
|
||||
<StatGrid $side={side}>
|
||||
<StatRow $side={side}>
|
||||
<StatLabel>ATK</StatLabel>
|
||||
<span style={{ fontWeight: "bold", fontFamily: t.font.mono, fontSize: 14, color: statColors.attack }}>
|
||||
{unit.attack}{items.some(i => i.effect.includes("melee")) ? `+${items.reduce((s, i) => s + (parseInt(i.effect) || 0), 0)}` : ""}
|
||||
</span>
|
||||
</StatRow>
|
||||
<StatRow $side={side}>
|
||||
<StatLabel>DEF</StatLabel>
|
||||
<span style={{ fontWeight: "bold", fontFamily: t.font.mono, fontSize: 14, color: statColors.defense }}>{unit.defense}</span>
|
||||
</StatRow>
|
||||
<StatRow $side={side}>
|
||||
<StatLabel>HP</StatLabel>
|
||||
<span style={{ fontWeight: "bold", fontFamily: t.font.mono, fontSize: 14, color: statColors.hp }}>{currentHp}/{unit.hp}</span>
|
||||
</StatRow>
|
||||
<StatRow $side={side}>
|
||||
<StatLabel>MOV</StatLabel>
|
||||
<span style={{ fontWeight: "bold", fontFamily: t.font.mono, fontSize: 14, color: statColors.movement }}>{unit.movement}</span>
|
||||
</StatRow>
|
||||
</StatGrid>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<ItemsLabel $side={side}>Items equipped</ItemsLabel>
|
||||
<div style={{ textAlign: side === "defender" ? "right" : "left" }}>
|
||||
{items.map(item => (
|
||||
<ItemChip key={item.name} title={item.source}>
|
||||
{item.name} · {item.effect}
|
||||
</ItemChip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModifierList
|
||||
title={side === "attacker" ? "Attack calculation" : "Defense / counter"}
|
||||
mods={modifiers}
|
||||
align={side === "defender" ? "right" : "left"}
|
||||
/>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
107
.project/designs/app/src/components/combat/DamageMatrix.tsx
Normal file
107
.project/designs/app/src/components/combat/DamageMatrix.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
import { DAMAGE_MATRIX, type AttackType, type ArmorType } from "../../data/units";
|
||||
|
||||
interface Props {
|
||||
highlightAtk?: AttackType;
|
||||
highlightArmor?: ArmorType;
|
||||
}
|
||||
|
||||
const ATK_TYPES: AttackType[] = ["blade", "pierce", "crush", "trample", "siege"];
|
||||
const ARMOR_TYPES: ArmorType[] = ["unarmored", "light", "medium", "armored", "heavy", "plate", "fortified"];
|
||||
|
||||
const Wrap = styled.div`
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
background: #1a1228;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid ${t.border.panel};
|
||||
font-size: 11px;
|
||||
color: ${t.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
`;
|
||||
|
||||
const Table = styled.table`
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
font-family: ${t.font.mono};
|
||||
`;
|
||||
|
||||
const Th = styled.th<{ $highlight?: boolean }>`
|
||||
background: rgba(31,23,51,0.5);
|
||||
color: ${({ $highlight }) => ($highlight ? t.accent.gold : t.text.muted)};
|
||||
padding: 5px 8px;
|
||||
border: 1px solid ${({ $highlight }) => ($highlight ? t.accent.gold : "#73591f22")};
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
`;
|
||||
|
||||
function cellStyle(mult: number, activeCell: boolean): string {
|
||||
let bg = "transparent", color = t.text.secondary;
|
||||
if (mult >= 1.5) { bg = "#1a0808"; color = t.sem.negative; }
|
||||
else if (mult >= 1.25) { bg = "#1a1208"; color = t.sem.warning; }
|
||||
else if (mult <= 0.5) { bg = "#061408"; color = t.accent.sage; }
|
||||
else if (mult < 1.0) { bg = "#081a08"; color = t.accent.sage; }
|
||||
if (activeCell) { bg = "#2a1a06"; }
|
||||
return `background:${bg};color:${color};`;
|
||||
}
|
||||
|
||||
const Td = styled.td<{ $mult: number; $active: boolean; $colHighlight: boolean }>`
|
||||
padding: 4px 8px;
|
||||
text-align: center;
|
||||
border: ${({ $active }) => ($active ? `2px solid ${t.accent.gold}` : "1px solid #73591f22")};
|
||||
font-weight: ${({ $mult }) => ($mult >= 1.5 || $mult <= 0.5 ? "bold" : "normal")};
|
||||
${({ $mult, $active }) => cellStyle($mult, $active)}
|
||||
`;
|
||||
|
||||
export function DamageMatrix({ highlightAtk, highlightArmor }: Props): React.ReactElement {
|
||||
return (
|
||||
<Wrap>
|
||||
<Header>
|
||||
Damage Matrix — multiplier applied to base attack
|
||||
{highlightAtk && highlightArmor && (
|
||||
<> · active: <strong style={{ color: t.sem.negative }}>{highlightAtk}</strong> vs <strong style={{ color: t.accent.science }}>{highlightArmor}</strong> = <strong style={{ color: t.sem.warning }}>{Math.round(DAMAGE_MATRIX[highlightAtk][highlightArmor] * 100)}%</strong></>
|
||||
)}
|
||||
</Header>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Attack \ Armor</Th>
|
||||
{ARMOR_TYPES.map(armor => (
|
||||
<Th key={armor} $highlight={armor === highlightArmor}>{armor}</Th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ATK_TYPES.map(atk => (
|
||||
<tr key={atk}>
|
||||
<Th $highlight={atk === highlightAtk}>{atk}</Th>
|
||||
{ARMOR_TYPES.map(armor => {
|
||||
const mult = DAMAGE_MATRIX[atk][armor];
|
||||
return (
|
||||
<Td
|
||||
key={armor}
|
||||
$mult={mult}
|
||||
$active={atk === highlightAtk && armor === highlightArmor}
|
||||
$colHighlight={armor === highlightArmor}
|
||||
>
|
||||
{Math.round(mult * 100)}%
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
144
.project/designs/app/src/components/combat/HpAfterBar.tsx
Normal file
144
.project/designs/app/src/components/combat/HpAfterBar.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
dmgMin: number;
|
||||
dmgMax: number;
|
||||
}
|
||||
|
||||
const Block = styled.div`
|
||||
background: rgba(31,23,51,0.6);
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 3px;
|
||||
padding: 10px 12px;
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
font-size: 10px;
|
||||
color: ${t.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const DeathBadge = styled.span`
|
||||
background: #3d0a08;
|
||||
border: 1px solid ${t.sem.negative};
|
||||
border-radius: 2px;
|
||||
padding: 1px 5px;
|
||||
font-size: 9px;
|
||||
color: ${t.sem.negative};
|
||||
letter-spacing: 0.06em;
|
||||
`;
|
||||
|
||||
const Track = styled.div`
|
||||
height: 20px;
|
||||
background: #ffffff08;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid ${t.border.divider};
|
||||
`;
|
||||
|
||||
const TrackSegment = styled.div<{ $left: number; $width: number; $color: string }>`
|
||||
position: absolute;
|
||||
top: 0; bottom: 0;
|
||||
left: ${({ $left }) => $left}%;
|
||||
width: ${({ $width }) => $width}%;
|
||||
background: ${({ $color }) => $color};
|
||||
`;
|
||||
|
||||
const CursorLine = styled.div<{ $pos: number }>`
|
||||
position: absolute;
|
||||
top: 0; bottom: 0;
|
||||
left: ${({ $pos }) => $pos}%;
|
||||
width: 2px;
|
||||
background: #ffffff66;
|
||||
`;
|
||||
|
||||
const ZeroLine = styled.div`
|
||||
position: absolute;
|
||||
top: 0; bottom: 0; left: 0;
|
||||
width: 2px;
|
||||
background: ${t.sem.negative};
|
||||
`;
|
||||
|
||||
const Nums = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
font-family: ${t.font.mono};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const DeathProb = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${t.sem.negative};
|
||||
margin-top: 5px;
|
||||
font-family: ${t.font.mono};
|
||||
`;
|
||||
|
||||
export function HpAfterBar({ label, currentHp, maxHp, dmgMin, dmgMax }: Props): React.ReactElement {
|
||||
const minRemaining = currentHp - dmgMax;
|
||||
const maxRemaining = currentHp - dmgMin;
|
||||
const deathPossible = minRemaining < 0;
|
||||
|
||||
const deathProb = deathPossible
|
||||
? Math.round(((dmgMax - currentHp) / (dmgMax - dmgMin)) * 100)
|
||||
: 0;
|
||||
|
||||
const scale = maxHp;
|
||||
|
||||
// surviving zone: 0 → clamp(minRemaining, 0, maxHp)
|
||||
const sureW = Math.max(0, Math.min(minRemaining, maxHp)) / scale * 100;
|
||||
// possible zone: minRemaining → maxRemaining (both clamped to [0, maxHp])
|
||||
const maybeL = Math.max(0, minRemaining) / scale * 100;
|
||||
const maybeW = (Math.min(maxRemaining, maxHp) - Math.max(0, minRemaining)) / scale * 100;
|
||||
const cursorPos = currentHp / scale * 100;
|
||||
|
||||
return (
|
||||
<Block>
|
||||
<Label>
|
||||
<span>{label}</span>
|
||||
{deathPossible && <DeathBadge>⚠ DEATH POSSIBLE</DeathBadge>}
|
||||
</Label>
|
||||
<Track>
|
||||
{deathPossible && <ZeroLine />}
|
||||
{deathPossible && (
|
||||
<TrackSegment $left={0} $width={sureW} $color="#d9594033" />
|
||||
)}
|
||||
{!deathPossible && (
|
||||
<TrackSegment $left={0} $width={sureW} $color="#66b86666" />
|
||||
)}
|
||||
<TrackSegment $left={maybeL} $width={Math.max(0, maybeW)} $color="#e6993344" />
|
||||
<CursorLine $pos={Math.min(cursorPos, 100)} />
|
||||
</Track>
|
||||
<Nums>
|
||||
{deathPossible ? (
|
||||
<>
|
||||
<span style={{ color: t.sem.negative }}>💀 min {minRemaining} (dead)</span>
|
||||
<span style={{ color: t.sem.warning }}>avg {Math.round(currentHp - (dmgMin + dmgMax) / 2)}</span>
|
||||
<span style={{ color: t.accent.sage }}>max {maxRemaining}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: t.sem.negative }}>min {minRemaining}</span>
|
||||
<span style={{ color: t.sem.warning }}>avg {Math.round(currentHp - (dmgMin + dmgMax) / 2)}</span>
|
||||
<span style={{ color: t.accent.sage }}>max {maxRemaining} · cannot die</span>
|
||||
</>
|
||||
)}
|
||||
</Nums>
|
||||
{deathPossible && (
|
||||
<DeathProb>
|
||||
death probability: ({dmgMax}−{currentHp})÷({dmgMax}−{dmgMin}) = {dmgMax - currentHp}/{dmgMax - dmgMin} ≈ <strong>{deathProb}%</strong>
|
||||
</DeathProb>
|
||||
)}
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
31
.project/designs/app/src/components/combat/KwBanner.tsx
Normal file
31
.project/designs/app/src/components/combat/KwBanner.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
import type { Banner } from "../../data/scenarios";
|
||||
|
||||
const styles = {
|
||||
warn: { bg: "#3d2000", border: "#e6993355", color: t.sem.warning },
|
||||
danger: { bg: "#3d0a08", border: "#d9594055", color: t.sem.negative },
|
||||
good: { bg: "#0a1a08", border: "#66b86655", color: t.accent.sage },
|
||||
info: { bg: "#0a1428", border: "#66bfff44", color: t.accent.science },
|
||||
};
|
||||
|
||||
const El = styled.div<{ $type: Banner["type"] }>`
|
||||
margin: 4px 0;
|
||||
padding: 8px 14px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: ${({ $type }) => styles[$type].bg};
|
||||
border: 1px solid ${({ $type }) => styles[$type].border};
|
||||
color: ${({ $type }) => styles[$type].color};
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
banner: Banner;
|
||||
}
|
||||
|
||||
export function KwBanner({ banner }: Props): React.ReactElement {
|
||||
return <El $type={banner.type}>{banner.text}</El>;
|
||||
}
|
||||
61
.project/designs/app/src/components/combat/ModifierList.tsx
Normal file
61
.project/designs/app/src/components/combat/ModifierList.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
import type { Modifier } from "../../data/scenarios";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
mods: Modifier[];
|
||||
align?: "left" | "right";
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
background: rgba(10,8,16,0.5);
|
||||
border: 1px solid ${t.border.divider};
|
||||
border-radius: 3px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
color: ${t.text.muted};
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
|
||||
const Row = styled.div<{ $align: "left" | "right"; $type: Modifier["type"] }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: ${({ $align }) => ($align === "right" ? "row-reverse" : "row")};
|
||||
padding: 2px 0;
|
||||
color: ${t.text.secondary};
|
||||
border-bottom: 1px solid #73591f18;
|
||||
font-weight: ${({ $type }) => ($type === "final" ? "bold" : "normal")};
|
||||
color: ${({ $type }) => ($type === "final" ? t.text.primary : t.text.secondary)};
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
`;
|
||||
|
||||
const Val = styled.span<{ $type: Modifier["type"] }>`
|
||||
font-family: ${t.font.mono};
|
||||
color: ${({ $type }) =>
|
||||
$type === "multiply" ? t.sem.warning :
|
||||
$type === "add" ? t.accent.sage :
|
||||
$type === "final" ? t.accent.gold :
|
||||
t.text.secondary};
|
||||
`;
|
||||
|
||||
export function ModifierList({ title, mods, align = "left" }: Props): React.ReactElement {
|
||||
return (
|
||||
<Wrap>
|
||||
<Title>{title}</Title>
|
||||
{mods.map((m, i) => (
|
||||
<Row key={i} $align={align} $type={m.type}>
|
||||
<span>{m.label}</span>
|
||||
<Val $type={m.type}>{m.value}</Val>
|
||||
</Row>
|
||||
))}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
|
||||
interface Props {
|
||||
effAtk: number;
|
||||
effDef: number;
|
||||
atkLabel?: string;
|
||||
defLabel?: string;
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
margin-bottom: 14px;
|
||||
`;
|
||||
|
||||
const Formula = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${t.text.muted};
|
||||
font-family: ${t.font.mono};
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const Bar = styled.div`
|
||||
height: 28px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const AtkSide = styled.div<{ $pct: number }>`
|
||||
width: ${({ $pct }) => $pct}%;
|
||||
background: linear-gradient(90deg, #d9594055, #d9594088);
|
||||
border-right: 2px solid ${t.sem.negative};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: ${t.sem.negative};
|
||||
`;
|
||||
|
||||
const DefSide = styled.div`
|
||||
flex: 1;
|
||||
background: linear-gradient(90deg, #66b86622, #66b86655);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: ${t.accent.sage};
|
||||
`;
|
||||
|
||||
const Labels = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: ${t.text.muted};
|
||||
margin-top: 3px;
|
||||
`;
|
||||
|
||||
export function ProbabilityBar({ effAtk, effDef, atkLabel = "Attacker wins", defLabel = "Defender survives" }: Props): React.ReactElement {
|
||||
const pct = Math.round((effAtk / (effAtk + effDef)) * 100);
|
||||
return (
|
||||
<Wrap>
|
||||
<Formula>
|
||||
win% = {effAtk.toFixed(1)} / ({effAtk.toFixed(1)} + {effDef.toFixed(1)}) ={" "}
|
||||
<strong style={{ color: t.accent.gold }}>{pct}%</strong>
|
||||
</Formula>
|
||||
<Bar>
|
||||
<AtkSide $pct={pct}>{pct}%</AtkSide>
|
||||
<DefSide>{100 - pct}%</DefSide>
|
||||
</Bar>
|
||||
<Labels>
|
||||
<span style={{ color: t.sem.negative }}>{atkLabel}</span>
|
||||
<span style={{ color: t.accent.sage }}>{defLabel}</span>
|
||||
</Labels>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
60
.project/designs/app/src/components/ui/Button.tsx
Normal file
60
.project/designs/app/src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
|
||||
export const Btn = styled.button`
|
||||
font-family: ${t.font.body};
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: ${t.text.btn};
|
||||
background: ${t.bg.btnNormal};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: ${t.radius.btn};
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
&:hover {
|
||||
background: ${t.bg.btnHover};
|
||||
border-color: ${t.accent.goldBright};
|
||||
color: ${t.text.btnHover};
|
||||
}
|
||||
`;
|
||||
|
||||
export const BtnAttack = styled(Btn)`
|
||||
flex: 1;
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 18px;
|
||||
color: ${t.sem.negative};
|
||||
background: #3d0f08;
|
||||
border: 2px solid ${t.sem.negative};
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.06em;
|
||||
|
||||
&:hover { background: #5a1510; }
|
||||
`;
|
||||
|
||||
export const BtnAttackRisky = styled(BtnAttack)`
|
||||
background: #5a0808;
|
||||
border-color: #ff6644;
|
||||
`;
|
||||
|
||||
export const BtnCancel = styled(Btn)`
|
||||
color: ${t.text.muted};
|
||||
padding: 10px 20px;
|
||||
`;
|
||||
|
||||
export const BtnEndTurn = styled(Btn)`
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 18px;
|
||||
color: ${t.text.title};
|
||||
background: #2a1a06;
|
||||
border: 2px solid ${t.accent.gold};
|
||||
padding: 10px 32px;
|
||||
letter-spacing: 0.06em;
|
||||
width: 200px;
|
||||
text-align: center;
|
||||
|
||||
&:hover { background: #3d2608; border-color: ${t.accent.goldBright}; }
|
||||
`;
|
||||
44
.project/designs/app/src/components/ui/Panel.tsx
Normal file
44
.project/designs/app/src/components/ui/Panel.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
|
||||
export const Panel = styled.div`
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: ${t.radius.panel};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const PanelHeader = styled.div`
|
||||
background: #1a1228;
|
||||
border-bottom: 2px solid ${t.border.panel};
|
||||
padding: 14px 20px;
|
||||
`;
|
||||
|
||||
export const PanelTitle = styled.div`
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 20px;
|
||||
color: ${t.text.title};
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
export const PanelSub = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${t.text.secondary};
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
export const SectionTitle = styled.div`
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 16px;
|
||||
color: ${t.accent.gold};
|
||||
letter-spacing: 0.05em;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid ${t.border.divider};
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
export const Divider = styled.hr`
|
||||
border: none;
|
||||
border-top: 1px solid ${t.border.divider};
|
||||
margin: 10px 0;
|
||||
`;
|
||||
41
.project/designs/app/src/components/ui/Tabs.tsx
Normal file
41
.project/designs/app/src/components/ui/Tabs.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
|
||||
export const TabBar = styled.div`
|
||||
display: flex;
|
||||
border-bottom: 1px solid ${t.border.panel};
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const TabEl = styled.button<{ $active: boolean }>`
|
||||
font-family: ${t.font.body};
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding: 9px 18px;
|
||||
color: ${({ $active }) => ($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: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color 150ms ease;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
tabs: string[];
|
||||
active: number;
|
||||
onChange: (i: number) => void;
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, active, onChange }: Props): React.ReactElement {
|
||||
return (
|
||||
<TabBar>
|
||||
{tabs.map((label, i) => (
|
||||
<TabEl key={i} $active={i === active} onClick={() => onChange(i)}>
|
||||
{label}
|
||||
</TabEl>
|
||||
))}
|
||||
</TabBar>
|
||||
);
|
||||
}
|
||||
40
.project/designs/app/src/components/ui/Tag.tsx
Normal file
40
.project/designs/app/src/components/ui/Tag.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import styled from "styled-components";
|
||||
import { t } from "../../theme";
|
||||
|
||||
type TagVariant = "atk" | "arm" | "kw" | "kwActive" | "kwDanger" | "item";
|
||||
|
||||
interface Props {
|
||||
variant: TagVariant;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<TagVariant, string> = {
|
||||
atk: `background:#3d0f08;border-color:#d9594066;color:#d9594099;`,
|
||||
arm: `background:#0a1428;border-color:#66bfff44;color:#66bfff88;`,
|
||||
kw: `background:#1a1208;border-color:#d9a02066;color:#d9a02099;`,
|
||||
kwActive: `background:#2a1a06;border-color:${t.accent.gold};color:${t.accent.gold};`,
|
||||
kwDanger: `background:#3d0f08;border-color:#d9594066;color:#d95940bb;`,
|
||||
item: `background:#0a180a;border-color:#66b86655;color:#66b866aa;`,
|
||||
};
|
||||
|
||||
const TagEl = styled.span<{ $variant: TagVariant }>`
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-family: ${t.font.mono};
|
||||
font-weight: bold;
|
||||
border: 1px solid;
|
||||
${({ $variant }) => variantStyles[$variant]}
|
||||
`;
|
||||
|
||||
export function Tag({ variant, children }: Props): React.ReactElement {
|
||||
return <TagEl $variant={variant}>{children}</TagEl>;
|
||||
}
|
||||
|
||||
export const TagRow = styled.div<{ $align?: "end" }>`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: ${({ $align }) => ($align === "end" ? "flex-end" : "flex-start")};
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
252
.project/designs/app/src/data/scenarios.ts
Normal file
252
.project/designs/app/src/data/scenarios.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { UNITS, matrixMultiplier, type Unit } from "./units";
|
||||
|
||||
export interface Modifier {
|
||||
label: string;
|
||||
value: string;
|
||||
type: "base" | "multiply" | "add" | "final";
|
||||
}
|
||||
|
||||
export interface EquippedItem {
|
||||
name: string;
|
||||
effect: string;
|
||||
source: string; // e.g. "Forge · 100⚒ · 2× mithril_vein"
|
||||
}
|
||||
|
||||
export interface CombatantState {
|
||||
unit: Unit;
|
||||
currentHp: number;
|
||||
clan: string;
|
||||
clanColor: string;
|
||||
promotions: string[];
|
||||
items: EquippedItem[];
|
||||
modifiers: Modifier[];
|
||||
effectiveStat: number; // effective attack (attacker) or effective defense (defender)
|
||||
}
|
||||
|
||||
export interface DamageRange {
|
||||
min: number;
|
||||
avg: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface Banner {
|
||||
type: "warn" | "danger" | "good" | "info";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CombatScenario {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
terrain: string;
|
||||
attacker: CombatantState;
|
||||
defender: CombatantState;
|
||||
damageToDefender: DamageRange;
|
||||
damageToAttacker: DamageRange;
|
||||
banners: Banner[];
|
||||
matrixLabel: string;
|
||||
matrixResultLabel: string;
|
||||
matrixResultClass: "neutral" | "good" | "great" | "bad";
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function pct(n: number): string {
|
||||
return `${Math.round(n * 100)}%`;
|
||||
}
|
||||
|
||||
function fmtMult(n: number): string {
|
||||
return `×${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// ── Scenario data ──────────────────────────────────────────────────────────
|
||||
|
||||
const warrior = UNITS.warrior;
|
||||
const berserker = UNITS.berserker;
|
||||
const ironwarden = UNITS.ironwarden;
|
||||
const pikeman = UNITS.pikeman;
|
||||
const spearmen = UNITS.spearmen;
|
||||
const cavalry = UNITS.cavalry;
|
||||
|
||||
// Scenario 0: Warrior (68 hp) vs Pikeman (100 hp)
|
||||
// blade vs light = 1.25; Shock I +15% open terrain
|
||||
// eff atk = 14 * 1.25 * 1.15 = 20.1
|
||||
// counter: pierce vs light = 1.00; no promotions → eff = 10
|
||||
// dmg to pikeman: base 20.1, range ±20% → 16–24
|
||||
// counter to warrior: base 10, range ±20% → 8–12
|
||||
export const s0: CombatScenario = {
|
||||
id: "s0",
|
||||
title: "Warrior attacks Pikeman",
|
||||
subtitle: "Plains (2,4) · Melee · Blade vs Light · warrior.json / pikeman.json",
|
||||
terrain: "Plains",
|
||||
attacker: {
|
||||
unit: warrior, currentHp: 68, clan: "Stoneguard (You)", clanColor: "#6699ff",
|
||||
promotions: ["Shock I"],
|
||||
items: [],
|
||||
modifiers: [
|
||||
{ label: "Base attack", value: "14", type: "base" },
|
||||
{ label: `Blade vs Light (${pct(matrixMultiplier("blade", "light"))})`, value: fmtMult(matrixMultiplier("blade","light")), type: "multiply" },
|
||||
{ label: "Shock I · open terrain", value: "+15%", type: "add" },
|
||||
{ label: "Effective attack", value: "20.1", type: "final" },
|
||||
],
|
||||
effectiveStat: 20.1,
|
||||
},
|
||||
defender: {
|
||||
unit: pikeman, currentHp: 100, clan: "Emberfall Clan", clanColor: "#ff8888",
|
||||
promotions: [],
|
||||
items: [],
|
||||
modifiers: [
|
||||
{ label: "Base defense", value: "14", type: "base" },
|
||||
{ label: "Plains terrain", value: "+0%", type: "add" },
|
||||
{ label: "No promotions", value: "—", type: "add" },
|
||||
{ label: "Effective defense", value: "14.0", type: "final" },
|
||||
],
|
||||
effectiveStat: 14.0,
|
||||
},
|
||||
damageToDefender: { min: 16, avg: 20, max: 24 },
|
||||
damageToAttacker: { min: 8, avg: 10, max: 12 },
|
||||
banners: [],
|
||||
matrixLabel: "blade vs light",
|
||||
matrixResultLabel: "Favourable matchup (125%)",
|
||||
matrixResultClass: "good",
|
||||
};
|
||||
|
||||
// Scenario 1: Berserker with RAGE (90 hp) vs Warrior (80 hp)
|
||||
// blade vs light = 1.25; RAGE +25%
|
||||
// eff atk = 20 * 1.25 * 1.25 = 31.25
|
||||
// counter: 14 * 1.25 = 17.5
|
||||
export const s1: CombatScenario = {
|
||||
id: "s1",
|
||||
title: "Berserker (RAGE active) attacks Warrior",
|
||||
subtitle: "Plains · Blade vs Light · berserker.json / warrior.json",
|
||||
terrain: "Plains",
|
||||
attacker: {
|
||||
unit: berserker, currentHp: 90, clan: "Stoneguard (You)", clanColor: "#6699ff",
|
||||
promotions: ["Drill I"],
|
||||
items: [],
|
||||
modifiers: [
|
||||
{ label: "Base attack", value: "20", type: "base" },
|
||||
{ label: `Blade vs Light (${pct(matrixMultiplier("blade","light"))})`, value: fmtMult(matrixMultiplier("blade","light")), type: "multiply" },
|
||||
{ label: "RAGE keyword (kill last turn)", value: "+25%", type: "add" },
|
||||
{ label: "Effective attack", value: "31.3", type: "final" },
|
||||
],
|
||||
effectiveStat: 31.3,
|
||||
},
|
||||
defender: {
|
||||
unit: warrior, currentHp: 80, clan: "Emberfall Clan", clanColor: "#ff8888",
|
||||
promotions: [],
|
||||
items: [],
|
||||
modifiers: [
|
||||
{ label: "Base defense", value: "8", type: "base" },
|
||||
{ label: "No promotions", value: "—", type: "add" },
|
||||
{ label: "Effective defense", value: "8.0", type: "final" },
|
||||
],
|
||||
effectiveStat: 8.0,
|
||||
},
|
||||
damageToDefender: { min: 25, avg: 31, max: 38 },
|
||||
damageToAttacker: { min: 10, avg: 14, max: 18 },
|
||||
banners: [
|
||||
{ type: "danger", text: "🔥 RAGE active — killed a unit last turn. +25% attack this turn only. Resets after." },
|
||||
{ type: "warn", text: "🛡 no_shield — Berserker cannot benefit from Cover promotions (ranged defense disabled)." },
|
||||
],
|
||||
matrixLabel: "blade vs light + rage",
|
||||
matrixResultLabel: "Devastating (125% + rage +25%)",
|
||||
matrixResultClass: "great",
|
||||
};
|
||||
|
||||
// Scenario 2: Ironwarden (items) vs Berserker WOUNDED 45/90
|
||||
// Master Blade +6 → atk 28; blade vs light 125%; Shock II +30% open → 28*1.25*1.30 = 45.5
|
||||
// counter: berserker blade vs heavy = 0.75 → 20*0.75 = 15.0
|
||||
// Berserker at 45 hp; takes 36–57 → min 45-57=-12 DEAD, max 45-36=9
|
||||
export const s2: CombatScenario = {
|
||||
id: "s2",
|
||||
title: "Ironwarden (items) vs Berserker (wounded)",
|
||||
subtitle: "Hills · Blade vs Light · ironwarden.json / berserker.json · items equipped",
|
||||
terrain: "Hills",
|
||||
attacker: {
|
||||
unit: ironwarden, currentHp: 110, clan: "Stoneguard (You)", clanColor: "#6699ff",
|
||||
promotions: ["Shock I", "Shock II"],
|
||||
items: [
|
||||
{ name: "Master Blade", effect: "+6 melee", source: "Forge · 100⚒ · 2× mithril_vein · tech: high_smithing" },
|
||||
{ name: "Tower Shield", effect: "+3 def / +2 vs ranged", source: "Smithy · 50⚒ · 2× iron_ore · tech: tactics" },
|
||||
],
|
||||
modifiers: [
|
||||
{ label: "Base attack", value: "22", type: "base" },
|
||||
{ label: "Master Blade (item)", value: "+6", type: "add" },
|
||||
{ label: `Blade vs Light (${pct(matrixMultiplier("blade","light"))})`, value: fmtMult(matrixMultiplier("blade","light")), type: "multiply" },
|
||||
{ label: "Shock II · hills terrain (no open bonus)", value: "—", type: "add" },
|
||||
{ label: "Effective attack", value: "45.5", type: "final" },
|
||||
],
|
||||
effectiveStat: 45.5,
|
||||
},
|
||||
defender: {
|
||||
unit: berserker, currentHp: 45, clan: "Emberfall Clan", clanColor: "#ff8888",
|
||||
promotions: [],
|
||||
items: [],
|
||||
modifiers: [
|
||||
{ label: "Base attack", value: "20", type: "base" },
|
||||
{ label: `Blade vs Heavy (${pct(matrixMultiplier("blade","heavy"))})`, value: fmtMult(matrixMultiplier("blade","heavy")), type: "multiply" },
|
||||
{ label: "No promotions", value: "—", type: "add" },
|
||||
{ label: "Effective counter", value: "15.0", type: "final" },
|
||||
],
|
||||
effectiveStat: 6.0, // defense stat used for win% denominator
|
||||
},
|
||||
damageToDefender: { min: 36, avg: 45, max: 57 },
|
||||
damageToAttacker: { min: 10, avg: 15, max: 20 },
|
||||
banners: [
|
||||
{ type: "good", text: "⚔ Master Blade — crafted at Forge (100⚒, 2× mithril_vein). +6 melee flat before matrix multiply." },
|
||||
{ type: "info", text: "🛡 Tower Shield — +3 defense flat, +2 additional vs ranged attacks. Requires tech: tactics." },
|
||||
{ type: "warn", text: "⚙ Blade vs Heavy = 75% — Berserker counter is heavily penalised. Use Pierce or Crush vs heavy armour." },
|
||||
],
|
||||
matrixLabel: "blade vs light (atk) / blade vs heavy (counter)",
|
||||
matrixResultLabel: "45.5 effective — 57 max damage",
|
||||
matrixResultClass: "great",
|
||||
};
|
||||
|
||||
// Scenario 3: Cavalry (wounded 28/70) vs Spearmen (60 hp)
|
||||
// Cavalry: blade vs medium = 1.00; flanking +15% → 16*1.00*1.15 = 18.4
|
||||
// Spearmen counter: pierce vs light = 1.25; anti_cavalry +100% → 8*1.25*2.00 = 20
|
||||
// Cavalry at 28 hp; takes 18–32 → 28-32=-4 DEAD, 28-18=10 max survive
|
||||
// death prob = (32-28)/(32-18) = 4/14 ≈ 29%
|
||||
export const s3: CombatScenario = {
|
||||
id: "s3",
|
||||
title: "Cavalry (wounded) vs Spearmen",
|
||||
subtitle: "Plains · Blade vs Medium · cavalry.json / spearmen.json · hard counter",
|
||||
terrain: "Plains",
|
||||
attacker: {
|
||||
unit: cavalry, currentHp: 28, clan: "Stoneguard (You)", clanColor: "#6699ff",
|
||||
promotions: [],
|
||||
items: [],
|
||||
modifiers: [
|
||||
{ label: "Base attack", value: "16", type: "base" },
|
||||
{ label: `Blade vs Medium (${pct(matrixMultiplier("blade","medium"))})`, value: fmtMult(matrixMultiplier("blade","medium")), type: "multiply" },
|
||||
{ label: "flanking keyword", value: "+15%", type: "add" },
|
||||
{ label: "Effective attack", value: "18.4", type: "final" },
|
||||
],
|
||||
effectiveStat: 18.4,
|
||||
},
|
||||
defender: {
|
||||
unit: spearmen, currentHp: 60, clan: "Emberfall Clan", clanColor: "#ff8888",
|
||||
promotions: [],
|
||||
items: [],
|
||||
modifiers: [
|
||||
{ label: "Base attack", value: "8", type: "base" },
|
||||
{ label: `Pierce vs Light (${pct(matrixMultiplier("pierce","light"))})`, value: fmtMult(matrixMultiplier("pierce","light")), type: "multiply" },
|
||||
{ label: "anti_cavalry keyword", value: "+100%", type: "add" },
|
||||
{ label: "Effective counter", value: "20.0", type: "final" },
|
||||
],
|
||||
effectiveStat: 20.0,
|
||||
},
|
||||
damageToDefender: { min: 12, avg: 18, max: 23 },
|
||||
damageToAttacker: { min: 18, avg: 25, max: 32 },
|
||||
banners: [
|
||||
{ type: "danger", text: "⚠ HARD COUNTER — anti_cavalry keyword gives Spearmen +100% vs Cavalry. Cavalry at 28 HP can die this turn." },
|
||||
{ type: "info", text: "🗡 reach keyword — Spearmen strike flying attackers and can First Strike against charging melee." },
|
||||
{ type: "warn", text: "🌿 Open terrain caveat: in Forest/City, Cavalry loses its advantage. Cavalry should never charge Anti-Cavalry." },
|
||||
],
|
||||
matrixLabel: "pierce vs light + anti_cavalry ×200%",
|
||||
matrixResultLabel: "Spearmen = hard counter",
|
||||
matrixResultClass: "great",
|
||||
};
|
||||
|
||||
export const SCENARIOS: CombatScenario[] = [s0, s1, s2, s3];
|
||||
12
.project/designs/app/src/main.tsx
Normal file
12
.project/designs/app/src/main.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("No #root element");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
222
.project/designs/app/src/pages/CombatPreview.tsx
Normal file
222
.project/designs/app/src/pages/CombatPreview.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { t } from "../theme";
|
||||
import { SCENARIOS } from "../data/scenarios";
|
||||
import { Tabs } from "../components/ui/Tabs";
|
||||
import { Panel, PanelHeader, PanelTitle, PanelSub } from "../components/ui/Panel";
|
||||
import { BtnAttack, BtnAttackRisky, BtnCancel } from "../components/ui/Button";
|
||||
import { DamageMatrix } from "../components/combat/DamageMatrix";
|
||||
import { CombatantCard } from "../components/combat/CombatantCard";
|
||||
import { ProbabilityBar } from "../components/combat/ProbabilityBar";
|
||||
import { HpAfterBar } from "../components/combat/HpAfterBar";
|
||||
import { KwBanner } from "../components/combat/KwBanner";
|
||||
|
||||
const Page = styled.div`
|
||||
max-width: 840px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
`;
|
||||
|
||||
const PageTitle = styled.div`
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 28px;
|
||||
color: ${t.text.title};
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const PageSub = styled.div`
|
||||
font-size: 12px;
|
||||
color: ${t.text.muted};
|
||||
font-family: ${t.font.mono};
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const SourceBadge = styled.span`
|
||||
display: inline-block;
|
||||
background: #0a1a0a;
|
||||
border: 1px solid #66b86644;
|
||||
border-radius: 2px;
|
||||
padding: 2px 7px;
|
||||
font-size: 10px;
|
||||
color: ${t.accent.sage};
|
||||
font-family: ${t.font.mono};
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const VsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 48px 1fr;
|
||||
padding: 20px 20px 0;
|
||||
`;
|
||||
|
||||
const VsCenter = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 20px;
|
||||
`;
|
||||
|
||||
const VsBadge = styled.div`
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 22px;
|
||||
color: ${t.sem.negative};
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid #d9594044;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const MatrixRow = styled.div`
|
||||
margin: 12px 20px 0;
|
||||
padding: 10px 14px;
|
||||
background: rgba(31,23,51,0.7);
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const OutcomeSection = styled.div`
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid ${t.border.divider};
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
const OutcomeTitle = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${t.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const HpGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
`;
|
||||
|
||||
const ActionRow = styled.div`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid ${t.border.divider};
|
||||
background: rgba(10,8,16,0.5);
|
||||
`;
|
||||
|
||||
const BannerWrap = styled.div`
|
||||
padding: 0 20px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const resultColors = {
|
||||
neutral: t.text.secondary,
|
||||
good: t.sem.warning,
|
||||
great: t.sem.negative,
|
||||
bad: t.accent.sage,
|
||||
};
|
||||
|
||||
const SCENARIO_TABS = ["⚔ Warrior vs Pikeman", "🪓 Berserker RAGE", "🛡 Items", "🐎 Trample Counter"];
|
||||
|
||||
export function CombatPreviewPage(): React.ReactElement {
|
||||
const [active, setActive] = useState(0);
|
||||
const s = SCENARIOS[active];
|
||||
|
||||
const isRisky = s.attacker.effectiveStat < s.defender.effectiveStat;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageTitle>Combat Preview</PageTitle>
|
||||
<PageSub>
|
||||
<SourceBadge>warrior.json</SourceBadge>
|
||||
<SourceBadge>berserker.json</SourceBadge>
|
||||
<SourceBadge>pikeman.json</SourceBadge>
|
||||
<SourceBadge>COMBAT_SYSTEM.md</SourceBadge>
|
||||
<SourceBadge>promotions.json</SourceBadge>
|
||||
<SourceBadge>items/</SourceBadge>
|
||||
— real unit stats, real promotions, real damage matrix
|
||||
</PageSub>
|
||||
|
||||
<DamageMatrix
|
||||
highlightAtk={s.attacker.unit.attackType}
|
||||
highlightArmor={s.defender.unit.armor}
|
||||
/>
|
||||
|
||||
<Tabs tabs={SCENARIO_TABS} active={active} onChange={setActive} />
|
||||
|
||||
<Panel key={s.id}>
|
||||
<PanelHeader style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<PanelTitle>{s.title}</PanelTitle>
|
||||
<PanelSub>{s.subtitle}</PanelSub>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<VsGrid>
|
||||
<CombatantCard state={s.attacker} side="attacker" />
|
||||
<VsCenter><VsBadge>VS</VsBadge></VsCenter>
|
||||
<CombatantCard state={s.defender} side="defender" />
|
||||
</VsGrid>
|
||||
|
||||
{s.banners.length > 0 && (
|
||||
<BannerWrap>
|
||||
{s.banners.map((b, i) => <KwBanner key={i} banner={b} />)}
|
||||
</BannerWrap>
|
||||
)}
|
||||
|
||||
<MatrixRow>
|
||||
<span style={{ color: t.text.muted, textTransform: "uppercase", fontSize: 11, letterSpacing: "0.08em" }}>Damage type</span>
|
||||
<span style={{ flex: 1, color: t.text.secondary, fontFamily: t.font.mono, fontSize: 12 }}>{s.matrixLabel}</span>
|
||||
<span style={{ fontFamily: t.font.mono, fontWeight: "bold", color: resultColors[s.matrixResultClass] }}>
|
||||
{s.matrixResultLabel}
|
||||
</span>
|
||||
</MatrixRow>
|
||||
|
||||
<OutcomeSection>
|
||||
<OutcomeTitle>Predicted Outcome</OutcomeTitle>
|
||||
|
||||
<ProbabilityBar
|
||||
effAtk={s.attacker.effectiveStat}
|
||||
effDef={s.defender.effectiveStat}
|
||||
/>
|
||||
|
||||
<HpGrid>
|
||||
<HpAfterBar
|
||||
label={`${s.attacker.unit.name} HP after (${s.attacker.currentHp} now)`}
|
||||
currentHp={s.attacker.currentHp}
|
||||
maxHp={s.attacker.unit.hp}
|
||||
dmgMin={s.damageToAttacker.min}
|
||||
dmgMax={s.damageToAttacker.max}
|
||||
/>
|
||||
<HpAfterBar
|
||||
label={`${s.defender.unit.name} HP after (${s.defender.currentHp} now)`}
|
||||
currentHp={s.defender.currentHp}
|
||||
maxHp={s.defender.unit.hp}
|
||||
dmgMin={s.damageToDefender.min}
|
||||
dmgMax={s.damageToDefender.max}
|
||||
/>
|
||||
</HpGrid>
|
||||
</OutcomeSection>
|
||||
|
||||
<ActionRow>
|
||||
{isRisky
|
||||
? <BtnAttackRisky>⚠ Risky — Confirm Anyway</BtnAttackRisky>
|
||||
: <BtnAttack>⚔ Confirm Attack</BtnAttack>
|
||||
}
|
||||
<BtnCancel>{isRisky ? "Cancel (Recommended)" : "Cancel"}</BtnCancel>
|
||||
</ActionRow>
|
||||
</Panel>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
76
.project/designs/app/src/pages/Index.tsx
Normal file
76
.project/designs/app/src/pages/Index.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { t } from "../theme";
|
||||
|
||||
const Page = styled.div`
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 36px;
|
||||
color: ${t.text.title};
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const Sub = styled.p`
|
||||
color: ${t.text.muted};
|
||||
font-size: 13px;
|
||||
margin-bottom: 32px;
|
||||
font-family: ${t.font.mono};
|
||||
`;
|
||||
|
||||
const NavList = styled.ul`
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
const NavItem = styled.li`
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const NavLink = styled(Link)`
|
||||
display: block;
|
||||
color: ${t.accent.gold};
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
padding: 10px 16px;
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 3px;
|
||||
transition: all 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background: ${t.bg.btnHover};
|
||||
border-color: ${t.accent.goldBright};
|
||||
color: ${t.text.btnHover};
|
||||
}
|
||||
`;
|
||||
|
||||
const routes = [
|
||||
{ path: "/gallery", label: "🖼 Design Gallery — colors, type, components" },
|
||||
{ path: "/hud", label: "🗺 World Map HUD — top bar, unit panel, minimap" },
|
||||
{ path: "/city", label: "🏛 City Screen — production queue, buildings, citizens" },
|
||||
{ path: "/menu", label: "⚒ Main Menu — atmospheric title screen" },
|
||||
{ path: "/tech", label: "⚗ Tech Tree — node graph, era bands, research" },
|
||||
{ path: "/combat", label: "⚔ Combat Preview — real stats, damage matrix, HP bars" },
|
||||
{ path: "/promotion", label: "★ Promotion Picker — grid, lock states, prereqs" },
|
||||
];
|
||||
|
||||
export function IndexPage(): React.ReactElement {
|
||||
return (
|
||||
<Page>
|
||||
<Title>Age of Dwarves</Title>
|
||||
<Sub>Design System · .project/designs/app · React + Vite + styled-components</Sub>
|
||||
<NavList>
|
||||
{routes.map(r => (
|
||||
<NavItem key={r.path}>
|
||||
<NavLink to={r.path}>{r.label}</NavLink>
|
||||
</NavItem>
|
||||
))}
|
||||
</NavList>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
|
|
@ -8,6 +8,40 @@ pnpmfileChecksum: sha256-pOgi3Q/PioTN3OH46Bs1frJvlmD0aNz/ZYybp7xmlws=
|
|||
|
||||
importers:
|
||||
|
||||
.project/designs/app:
|
||||
dependencies:
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.5
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.5(react@19.2.5)
|
||||
react-router-dom:
|
||||
specifier: ^7.5.3
|
||||
version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
styled-components:
|
||||
specifier: ^6.1.18
|
||||
version: 6.4.0(css-to-react-native@3.2.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^19.1.2
|
||||
version: 19.2.14
|
||||
'@types/react-dom':
|
||||
specifier: ^19.1.2
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/styled-components':
|
||||
specifier: ^5.1.34
|
||||
version: 5.1.36
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.4.1
|
||||
version: 4.7.0(vite@6.4.2(@types/node@25.6.0)(tsx@4.21.0))
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^6.3.3
|
||||
version: 6.4.2(@types/node@25.6.0)(tsx@4.21.0)
|
||||
|
||||
public/games/age-of-dwarves/guide:
|
||||
dependencies:
|
||||
'@lilith/ui-feedback':
|
||||
|
|
@ -1236,6 +1270,11 @@ packages:
|
|||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/hoist-non-react-statics@3.3.7':
|
||||
resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
|
|
@ -1262,6 +1301,9 @@ packages:
|
|||
'@types/stats.js@0.17.4':
|
||||
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||
|
||||
'@types/styled-components@5.1.36':
|
||||
resolution: {integrity: sha512-pGMRNY5G2rNDKEv2DOiFYa7Ft1r0jrhmgBwHhOMzPTgCjO76bCot0/4uEfqj7K0Jf1KdQmDtAuaDk9EAs9foSw==}
|
||||
|
||||
'@types/stylis@4.2.7':
|
||||
resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==}
|
||||
|
||||
|
|
@ -2058,6 +2100,9 @@ packages:
|
|||
hermes-parser@0.25.1:
|
||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
|
|
@ -2611,6 +2656,9 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.2.5
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -3861,6 +3909,11 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
hoist-non-react-statics: 3.3.2
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
|
@ -3885,6 +3938,12 @@ snapshots:
|
|||
|
||||
'@types/stats.js@0.17.4': {}
|
||||
|
||||
'@types/styled-components@5.1.36':
|
||||
dependencies:
|
||||
'@types/hoist-non-react-statics': 3.3.7(@types/react@19.2.14)
|
||||
'@types/react': 19.2.14
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/stylis@4.2.7': {}
|
||||
|
||||
'@types/three@0.183.1':
|
||||
|
|
@ -4840,6 +4899,10 @@ snapshots:
|
|||
dependencies:
|
||||
hermes-estree: 0.25.1
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
|
@ -5603,6 +5666,8 @@ snapshots:
|
|||
react: 19.2.5
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ packages:
|
|||
- src/simulator
|
||||
- src/packages/*
|
||||
- public/games/*/guide
|
||||
- .project/designs/app
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ cmd_guide() {
|
|||
|
||||
cmd_designs() {
|
||||
local port="${1:-7777}"
|
||||
echo -e "${BLUE}Starting design viewer (port ${port})...${NC}"
|
||||
echo -e "${BLUE}Starting design app (port ${port})...${NC}"
|
||||
echo -e "${BLUE} http://localhost:${port}${NC}"
|
||||
node "$REPO_ROOT/.project/designs/serve.js" "$port"
|
||||
pnpm --prefix "$REPO_ROOT/.project/designs/app" run dev -- --port "$port"
|
||||
}
|
||||
|
||||
cmd_screenshot() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue