feat(@projects/@magic-civilization): add combat system components

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-26 16:08:21 -07:00
parent 8163fb8fcb
commit 2fd9eced63
18 changed files with 1462 additions and 2 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View file

@ -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>
);
}

View 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}; }
`;

View 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;
`;

View 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>
);
}

View 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;
`;

View 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% → 1624
// counter to warrior: base 10, range ±20% → 812
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 3657 → 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 1832 → 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];

View 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>
);

View 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>
);
}

View 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
View file

@ -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

View file

@ -2,3 +2,4 @@ packages:
- src/simulator
- src/packages/*
- public/games/*/guide
- .project/designs/app

View file

@ -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() {