feat(@projects/@magic-civilization): add promotion picker page

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-29 13:03:09 -04:00
parent 7db83c3519
commit 2364c10c9c
2 changed files with 920 additions and 0 deletions

View file

@ -0,0 +1,406 @@
import { useState } from "react";
import styled from "styled-components";
import { t } from "../theme";
type PromoType = "combat" | "support" | "special";
interface Promo {
id: string;
icon: string;
name: string;
rank: number; // 1..3 stars filled
type: PromoType;
typeLabel: string;
desc: string;
effect: string;
effectColor: string;
locked?: boolean;
lockReason?: string;
}
const PROMOS: Promo[] = [
{ id: "combat-ii", icon: "⚔", name: "Combat II", rank: 2, type: "combat", typeLabel: "Combat",
desc: "Increases base combat strength when attacking.",
effect: "+10% attack strength", effectColor: t.sem.negative },
{ id: "shock", icon: "🗡", name: "Shock", rank: 2, type: "combat", typeLabel: "Combat",
desc: "Bonus strength when attacking units in open terrain.",
effect: "+15% vs units on plains / desert", effectColor: t.sem.negative },
{ id: "raider", icon: "🏰", name: "City Raider", rank: 2, type: "combat", typeLabel: "Combat",
desc: "Specialised for assaulting fortified positions and cities.",
effect: "+20% vs fortified / city units", effectColor: t.sem.negative },
{ id: "stalwart", icon: "🛡", name: "Stalwart", rank: 2, type: "support", typeLabel: "Defense",
desc: "Hardens the unit against incoming attacks.",
effect: "+10% defense strength", effectColor: t.accent.sage },
{ id: "medic", icon: "❤", name: "Medic I", rank: 2, type: "support", typeLabel: "Support",
desc: "Unit and adjacent friendly units heal faster each turn.",
effect: "+5 HP healed / turn (nearby units +2)", effectColor: t.accent.sage },
{ id: "march", icon: "💀", name: "March", rank: 2, type: "special", typeLabel: "Special",
desc: "Unit heals even when it attacks during a turn.",
effect: "Heal 10 HP per turn even when moving", effectColor: t.accent.science,
locked: true, lockReason: "🔒 Requires: Medic I" },
{ id: "blitz", icon: "⚡", name: "Blitz", rank: 2, type: "special", typeLabel: "Special",
desc: "May attack more than once per turn if first strike kills.",
effect: "Extra attack on kill", effectColor: t.accent.science },
{ id: "mountaineer", icon: "⛰", name: "Mountaineer", rank: 2, type: "combat", typeLabel: "Terrain",
desc: "Excels in rough highland terrain.",
effect: "+25% in hills / mountains", effectColor: t.sem.negative,
locked: true, lockReason: "🔒 Requires: Combat II" },
{ id: "scout", icon: "👁", name: "Scout", rank: 2, type: "support", typeLabel: "Recon",
desc: "Extends visibility range and reveals more fog of war.",
effect: "+1 vision range · reveals terrain faster", effectColor: t.accent.sage },
];
const PageWrap = styled.div`
background: rgba(0,0,0,0.7);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-family: ${t.font.body};
position: relative;
&::before {
content: '';
position: fixed; inset: 0;
background:
radial-gradient(ellipse at 30% 60%, #1a3319 0%, transparent 50%),
radial-gradient(ellipse at 70% 30%, #19231a 0%, transparent 40%),
#0d1208;
z-index: -1;
filter: blur(2px);
}
`;
const Modal = styled.div`
width: 660px;
background: ${t.bg.panel};
border: 1px solid ${t.border.panel};
border-radius: ${t.radius.panel};
box-shadow: 0 24px 80px rgba(0,0,0,0.9), 0 0 0 1px #73591f22;
overflow: hidden;
`;
const ModalHeader = styled.div`
background: linear-gradient(180deg, #2a1a06 0%, #1a1208 100%);
border-bottom: 2px solid ${t.accent.gold};
padding: 16px 20px;
display: flex;
align-items: center;
gap: 16px;
`;
const UnitPortrait = styled.div`
width: 56px; height: 56px;
border-radius: 50%;
border: 2px solid ${t.accent.gold};
background: radial-gradient(ellipse at 40% 40%, #2a1a06, ${t.bg.menu});
display: flex; align-items: center; justify-content: center;
font-size: 26px;
box-shadow: 0 0 16px #d9a02033;
flex-shrink: 0;
`;
const HeaderInfo = styled.div`
flex: 1;
`;
const HeaderTitle = styled.div`
font-family: ${t.font.heading};
font-size: 22px;
color: ${t.text.title};
letter-spacing: 0.04em;
margin-bottom: 2px;
`;
const HeaderSub = styled.div`
font-size: 12px;
color: ${t.text.secondary};
`;
const HeaderXp = styled.div`
text-align: right;
font-size: 12px;
color: ${t.text.muted};
`;
const HeaderXpVal = styled.span`
font-size: 20px;
font-weight: bold;
color: ${t.accent.gold};
font-family: ${t.font.mono};
display: block;
margin-bottom: 2px;
`;
const EarnedStrip = styled.div`
background: rgba(10,8,16,0.6);
border-bottom: 1px solid ${t.border.divider};
padding: 8px 20px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
`;
const EarnedLabel = styled.span`
font-size: 11px;
color: ${t.text.muted};
text-transform: uppercase;
letter-spacing: 0.08em;
flex-shrink: 0;
`;
const PromoTag = styled.div`
display: flex;
align-items: center;
gap: 4px;
background: #1a1208;
border: 1px solid ${t.accent.gold};
border-radius: 2px;
padding: 3px 8px;
font-size: 11px;
color: ${t.accent.gold};
`;
const SectionLabel = styled.div`
padding: 14px 20px 8px;
font-size: 11px;
color: ${t.text.muted};
text-transform: uppercase;
letter-spacing: 0.1em;
`;
const PromoGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
padding: 0 20px 16px;
`;
const typeAccent = (type: PromoType): string => {
switch (type) {
case "combat": return t.sem.negative;
case "support": return t.accent.sage;
case "special": return t.accent.science;
}
};
const PromoCard = styled.div<{ $type: PromoType; $selected: boolean; $locked: boolean }>`
background: ${p => p.$selected ? "#2a1a06" : t.bg.btnNormal};
border: 1px solid ${p => p.$selected ? t.accent.gold : "#73591f88"};
border-top: 2px solid ${p => p.$selected ? typeAccent(p.$type) : `${typeAccent(p.$type)}66`};
border-radius: ${t.radius.btn};
padding: 12px;
cursor: ${p => p.$locked ? "not-allowed" : "pointer"};
transition: all 150ms ease;
position: relative;
overflow: hidden;
opacity: ${p => p.$locked ? 0.4 : 1};
filter: ${p => p.$locked ? "grayscale(0.4)" : "none"};
box-shadow: ${p => p.$selected ? "0 0 20px #d9a02022" : "none"};
&:hover {
${p => !p.$locked && `
border-color: ${t.accent.goldBright};
background: #2a1a0a;
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
`}
}
`;
const PromoCardTop = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
`;
const PromoIcon = styled.div`
font-size: 22px;
flex-shrink: 0;
`;
const PromoName = styled.div`
font-size: 13px;
font-weight: bold;
color: ${t.text.primary};
line-height: 1.2;
`;
const PromoRank = styled.div`
display: flex;
gap: 2px;
margin-top: 2px;
`;
const Star = styled.span<{ $empty?: boolean }>`
color: ${p => p.$empty ? "#73591f44" : t.accent.gold};
font-size: 10px;
`;
const PromoTypeBadge = styled.div<{ $type: PromoType }>`
margin-left: auto;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 5px;
border-radius: 2px;
flex-shrink: 0;
background: ${p => `${typeAccent(p.$type)}22`};
color: ${p => `${typeAccent(p.$type)}99`};
border: 1px solid ${p => `${typeAccent(p.$type)}33`};
`;
const PromoDesc = styled.div`
font-size: 11px;
color: ${t.text.secondary};
line-height: 1.4;
margin-bottom: 6px;
`;
const PromoEffect = styled.div<{ $color: string }>`
font-size: 11px;
font-family: ${t.font.mono};
font-weight: bold;
color: ${p => p.$color};
`;
const SelectedCheck = styled.div`
position: absolute;
top: 8px; right: 8px;
width: 18px; height: 18px;
border-radius: 50%;
background: ${t.accent.gold};
display: flex; align-items: center; justify-content: center;
font-size: 11px;
color: #000;
`;
const LockReason = styled.div`
font-size: 10px;
color: ${t.sem.negative};
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
`;
const ModalFooter = styled.div`
border-top: 1px solid ${t.border.panel};
padding: 14px 20px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(10,8,16,0.5);
`;
const SelectedSummary = styled.div`
flex: 1;
font-size: 13px;
color: ${t.text.secondary};
`;
const SelectedName = styled.span`
color: ${t.text.title};
font-weight: bold;
`;
const BtnConfirm = styled.button`
font-family: ${t.font.heading};
font-size: 18px;
color: ${t.text.title};
background: #2a1a06;
border: 2px solid ${t.accent.gold};
border-radius: ${t.radius.btn};
padding: 10px 28px;
cursor: pointer;
letter-spacing: 0.05em;
transition: all 150ms ease;
&:hover { background: #3d2608; border-color: ${t.accent.goldBright}; }
`;
const BtnCancel = styled.button`
font-family: ${t.font.body};
font-size: 14px;
font-weight: 700;
color: ${t.text.muted};
background: ${t.bg.btnNormal};
border: 1px solid ${t.border.panel};
border-radius: ${t.radius.btn};
padding: 10px 18px;
cursor: pointer;
`;
export function PromotionPickerPage(): React.ReactElement {
const [selectedId, setSelectedId] = useState<string>("combat-ii");
const selected = PROMOS.find(p => p.id === selectedId);
return (
<PageWrap>
<Modal>
<ModalHeader>
<UnitPortrait></UnitPortrait>
<HeaderInfo>
<HeaderTitle>Promotion Available</HeaderTitle>
<HeaderSub>Iron Warrior · Melee Infantry · Stoneguard Clan</HeaderSub>
</HeaderInfo>
<HeaderXp>
<HeaderXpVal></HeaderXpVal>
Rank 2 · 48 XP
</HeaderXp>
</ModalHeader>
<EarnedStrip>
<EarnedLabel>Earned:</EarnedLabel>
<PromoTag> Strength I</PromoTag>
<PromoTag> Flanking</PromoTag>
</EarnedStrip>
<SectionLabel>Choose one promotion</SectionLabel>
<PromoGrid>
{PROMOS.map(p => {
const isSelected = p.id === selectedId;
return (
<PromoCard
key={p.id}
$type={p.type}
$selected={isSelected}
$locked={!!p.locked}
onClick={() => { if (!p.locked) setSelectedId(p.id); }}
>
{isSelected && <SelectedCheck></SelectedCheck>}
<PromoCardTop>
<PromoIcon>{p.icon}</PromoIcon>
<div>
<PromoName>{p.name}</PromoName>
<PromoRank>
{[0, 1, 2].map(i => (
<Star key={i} $empty={i >= p.rank}></Star>
))}
</PromoRank>
</div>
<PromoTypeBadge $type={p.type}>{p.typeLabel}</PromoTypeBadge>
</PromoCardTop>
<PromoDesc>{p.desc}</PromoDesc>
<PromoEffect $color={p.effectColor}>{p.effect}</PromoEffect>
{p.lockReason && <LockReason>{p.lockReason}</LockReason>}
</PromoCard>
);
})}
</PromoGrid>
<ModalFooter>
<SelectedSummary>
Selected: <SelectedName>{selected?.name ?? "—"}</SelectedName>
{selected && <span style={{ color: t.text.muted }}> · {selected.effect}</span>}
</SelectedSummary>
<BtnCancel>Back</BtnCancel>
<BtnConfirm>Promote </BtnConfirm>
</ModalFooter>
</Modal>
</PageWrap>
);
}

View file

@ -0,0 +1,514 @@
import { useState } from "react";
import styled from "styled-components";
import { t } from "../theme";
type NodeStatus = "researched" | "in-progress" | "available" | "locked";
type Pillar = "Military" | "Economy" | "Industry" | "Agriculture" | "Governance" | "Culture" | "Exploration" | "Engineering" | "Medicine";
interface TechNodeData {
id: string;
name: string;
icon: string;
status: NodeStatus;
cost: string;
unlocks: string;
x: number;
y: number;
progress?: number;
}
interface ConnectionData {
x1: number; y1: number; x2: number; y2: number;
stroke: string; width: number; dash?: string; opacity?: number;
animated?: boolean;
}
const PILLARS: Pillar[] = [
"Military", "Economy", "Industry", "Agriculture", "Governance",
"Culture", "Exploration", "Engineering", "Medicine",
];
const NODES: TechNodeData[] = [
// Stone
{ id: "stone-tools", name: "Stone Tools", icon: "🪨", status: "researched", cost: "✓ Researched", unlocks: "Worker · Quarry", x: 65, y: 95 },
{ id: "fire", name: "Fire Making", icon: "🔥", status: "researched", cost: "✓ Researched", unlocks: "Forge · Ale Hall", x: 65, y: 155 },
{ id: "agriculture", name: "Agriculture", icon: "🌾", status: "researched", cost: "✓ Researched", unlocks: "Farm · Granary", x: 65, y: 215 },
// Bronze
{ id: "bronze", name: "Bronze Casting", icon: "🥉", status: "researched", cost: "✓ Researched", unlocks: "Spearman · Shield", x: 300, y: 155 },
{ id: "mining", name: "Mining", icon: "⚒", status: "researched", cost: "✓ Researched", unlocks: "Mine · Pickaxe", x: 300, y: 260 },
{ id: "husbandry", name: "Animal Husbandry", icon: "🐂", status: "researched", cost: "✓ Researched", unlocks: "Pasture · War Boar", x: 300, y: 360 },
// Iron
{ id: "steel", name: "Steel Working", icon: "⚙", status: "in-progress", cost: "⚗ 88 / 120 · 4t", unlocks: "Iron Warrior · Forge +1", x: 550, y: 255, progress: 73 },
{ id: "archery", name: "Archery", icon: "🏹", status: "available", cost: "⚗ 120", unlocks: "Archer · Watchtower", x: 550, y: 355 },
{ id: "masonry", name: "Masonry", icon: "🏛", status: "available", cost: "⚗ 140", unlocks: "Walls · Monument", x: 550, y: 455 },
// Steel
{ id: "sword", name: "Swordsmanship", icon: "🗡", status: "locked", cost: "⚗ 200", unlocks: "Swordsman · Siege Eng.", x: 800, y: 325 },
{ id: "iron", name: "Iron Working", icon: "🔩", status: "locked", cost: "⚗ 180", unlocks: "Forge upgrade · Armorer", x: 800, y: 430 },
{ id: "hydra", name: "Hydraulics", icon: "🌊", status: "locked", cost: "⚗ 160", unlocks: "Aqueduct upgrade", x: 800, y: 540 },
// Late
{ id: "tactics", name: "Tactics", icon: "⚔", status: "locked", cost: "⚗ 280", unlocks: "Formation bonuses", x: 1050, y: 400 },
{ id: "engineering", name: "Engineering", icon: "🏗", status: "locked", cost: "⚗ 260", unlocks: "Catapult · Roads", x: 1050, y: 515 },
{ id: "writing", name: "Writing", icon: "📜", status: "locked", cost: "⚗ 220", unlocks: "Library · Diplomat", x: 1050, y: 630 },
// End
{ id: "mastery", name: "Dwarven Mastery", icon: "🏆", status: "locked", cost: "⚗ 400", unlocks: "Victory · All bonuses", x: 1300, y: 470 },
{ id: "alchemy", name: "Alchemy", icon: "⚗", status: "locked", cost: "⚗ 350", unlocks: "Alchemist Guild", x: 1300, y: 630 },
];
const CONNECTIONS: ConnectionData[] = [
{ x1: 195, y1: 130, x2: 300, y2: 185, stroke: "#d9a02055", width: 1.5 },
{ x1: 195, y1: 130, x2: 300, y2: 290, stroke: "#d9a02033", width: 1 },
{ x1: 195, y1: 130, x2: 300, y2: 390, stroke: "#d9a02022", width: 1 },
{ x1: 195, y1: 185, x2: 300, y2: 185, stroke: "#d9a02066", width: 1.5 },
{ x1: 195, y1: 185, x2: 300, y2: 290, stroke: "#d9a02033", width: 1 },
{ x1: 195, y1: 240, x2: 300, y2: 290, stroke: "#d9a02055", width: 1.5 },
{ x1: 195, y1: 240, x2: 300, y2: 390, stroke: "#d9a02033", width: 1 },
{ x1: 435, y1: 185, x2: 550, y2: 285, stroke: "#d9a02055", width: 1.5 },
{ x1: 435, y1: 185, x2: 550, y2: 385, stroke: "#66bfff66", width: 1.5, dash: "4,3" },
{ x1: 435, y1: 290, x2: 550, y2: 285, stroke: "#d9a02044", width: 1.5 },
{ x1: 435, y1: 290, x2: 550, y2: 485, stroke: "#d9a02033", width: 1 },
{ x1: 435, y1: 390, x2: 550, y2: 385, stroke: "#d9a02055", width: 1.5 },
{ x1: 435, y1: 390, x2: 550, y2: 485, stroke: "#d9a02033", width: 1 },
{ x1: 685, y1: 285, x2: 800, y2: 355, stroke: "#66bfff55", width: 1.5, dash: "4,3" },
{ x1: 685, y1: 285, x2: 800, y2: 460, stroke: "#d9a02033", width: 1 },
{ x1: 685, y1: 385, x2: 800, y2: 355, stroke: "#d9a02044", width: 1.5 },
{ x1: 685, y1: 385, x2: 800, y2: 460, stroke: "#d9a02055", width: 1.5 },
{ x1: 685, y1: 485, x2: 800, y2: 460, stroke: "#d9a02044", width: 1 },
{ x1: 685, y1: 485, x2: 800, y2: 570, stroke: "#d9a02033", width: 1 },
{ x1: 935, y1: 355, x2: 1050, y2: 430, stroke: "#d9a02044", width: 1.5 },
{ x1: 935, y1: 460, x2: 1050, y2: 430, stroke: "#d9a02044", width: 1.5 },
{ x1: 935, y1: 460, x2: 1050, y2: 545, stroke: "#d9a02033", width: 1 },
{ x1: 935, y1: 570, x2: 1050, y2: 545, stroke: "#d9a02033", width: 1 },
{ x1: 935, y1: 570, x2: 1050, y2: 660, stroke: "#d9a02022", width: 1 },
{ x1: 1185, y1: 430, x2: 1300, y2: 500, stroke: "#d9a02033", width: 1 },
{ x1: 1185, y1: 545, x2: 1300, y2: 500, stroke: "#d9a02033", width: 1 },
{ x1: 1185, y1: 660, x2: 1300, y2: 660, stroke: "#d9a02022", width: 1 },
{ x1: 195, y1: 130, x2: 300, y2: 185, stroke: "#d9a020", width: 2, opacity: 0.5 },
{ x1: 195, y1: 185, x2: 300, y2: 185, stroke: "#d9a020", width: 2, opacity: 0.5 },
{ x1: 195, y1: 240, x2: 300, y2: 290, stroke: "#d9a020", width: 2, opacity: 0.4 },
{ x1: 435, y1: 185, x2: 550, y2: 285, stroke: "#d9a020", width: 2, opacity: 0.35 },
{ x1: 550, y1: 285, x2: 685, y2: 285, stroke: "#66bfff", width: 2, dash: "6,4", opacity: 0.7, animated: true },
];
const ERAS = [
{ y: 60, label: "Stone Age" },
{ y: 240, label: "Bronze Age" },
{ y: 430, label: "Iron Age" },
{ y: 620, label: "Steel Age" },
];
const PageWrap = styled.div`
background: ${t.bg.menu};
color: ${t.text.primary};
font-family: ${t.font.body};
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
`;
const Header = styled.div`
background: ${t.bg.panel};
border-bottom: 1px solid ${t.border.panel};
padding: 12px 24px;
display: flex;
align-items: center;
gap: 24px;
flex-shrink: 0;
`;
const HeaderTitle = styled.div`
font-family: ${t.font.heading};
font-size: 22px;
color: ${t.text.title};
letter-spacing: 0.04em;
`;
const SciRate = styled.div`
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: ${t.accent.science};
background: #0d1a2a;
border: 1px solid #66bfff44;
border-radius: 3px;
padding: 5px 12px;
`;
const CurrentResearch = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: ${t.text.secondary};
margin-left: auto;
`;
const ResearchName = styled.span`
color: ${t.accent.science};
font-weight: bold;
`;
const ResearchTurns = styled.span`
color: ${t.accent.gold};
font-size: 12px;
`;
const CloseBtn = styled.div`
width: 32px; height: 32px;
background: ${t.bg.btnNormal};
border: 1px solid ${t.border.panel};
border-radius: 3px;
display: flex; align-items: center; justify-content: center;
color: ${t.text.muted};
cursor: pointer;
font-size: 16px;
`;
const PillarBar = styled.div`
background: rgba(23, 18, 30, 0.9);
border-bottom: 1px solid ${t.border.divider};
display: flex;
flex-shrink: 0;
padding: 0 20px;
`;
const PillarLabel = styled.div<{ $active?: boolean }>`
flex: 1;
text-align: center;
padding: 7px 4px;
font-size: 11px;
color: ${p => p.$active ? t.accent.gold : t.text.muted};
text-transform: uppercase;
letter-spacing: 0.08em;
border-right: 1px solid #73591f22;
&:last-child { border-right: none; }
`;
const FilterBar = styled.div`
position: fixed;
top: 108px; right: 16px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 50;
`;
const FilterChip = styled.div<{ $on?: boolean }>`
padding: 5px 10px;
border-radius: 3px;
border: 1px solid ${p => p.$on ? t.accent.goldBright : t.border.panel};
background: ${p => p.$on ? "#2a1a06" : t.bg.btnNormal};
font-size: 11px;
color: ${p => p.$on ? t.accent.gold : t.text.muted};
cursor: pointer;
text-align: center;
`;
const TreeScroll = styled.div`
flex: 1;
overflow: auto;
position: relative;
`;
const TreeCanvas = styled.div`
position: relative;
width: 1400px;
height: 820px;
padding: 24px 20px;
`;
const ConnectionsSvg = styled.svg`
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
`;
const EraBand = styled.div<{ $top: number }>`
position: absolute;
left: 0; right: 0;
top: ${p => p.$top}px;
border-top: 1px dashed ${t.border.divider};
z-index: 0;
`;
const EraLabel = styled.span`
position: absolute;
right: 12px;
font-size: 11px;
color: #73591f99;
text-transform: uppercase;
letter-spacing: 0.1em;
transform: translateY(-50%);
font-family: ${t.font.mono};
`;
const NodeBox = styled.div<{ $status: NodeStatus; $x: number; $y: number }>`
position: absolute;
left: ${p => p.$x}px;
top: ${p => p.$y}px;
width: 130px;
border-radius: 4px;
border: 1px solid ${p => {
switch (p.$status) {
case "researched": return t.accent.gold;
case "in-progress": return t.accent.science;
case "available": return "#73591f99";
case "locked": return t.border.panel;
}
}};
background: ${p => p.$status === "researched" ? "#1a1408" : t.bg.panel};
cursor: pointer;
z-index: 1;
transition: all 150ms ease;
opacity: ${p => p.$status === "locked" ? 0.45 : 1};
filter: ${p => p.$status === "locked" ? "grayscale(0.5)" : "none"};
box-shadow: ${p => p.$status === "in-progress" ? "0 0 12px #66bfff22" : "none"};
&:hover {
border-color: ${t.accent.goldBright};
transform: translateY(-2px);
box-shadow: 0 4px 16px #000a;
}
`;
const NodeHeader = styled.div<{ $status: NodeStatus }>`
padding: 7px 9px 5px;
border-bottom: 1px solid ${t.border.divider};
display: flex;
align-items: center;
gap: 7px;
background: ${p => {
switch (p.$status) {
case "researched": return "#2a1a06";
case "in-progress": return "#0a1a2a";
default: return "transparent";
}
}};
`;
const NodeIcon = styled.span`
font-size: 18px;
`;
const NodeName = styled.span<{ $status: NodeStatus }>`
font-size: 12px;
font-weight: bold;
line-height: 1.2;
color: ${p => {
switch (p.$status) {
case "researched": return t.accent.gold;
case "in-progress": return t.accent.science;
default: return t.text.primary;
}
}};
`;
const NodeBody = styled.div`
padding: 6px 9px 8px;
`;
const NodeCost = styled.div<{ $researched: boolean }>`
font-size: 11px;
color: ${p => p.$researched ? t.accent.sage : t.accent.science};
font-family: ${t.font.mono};
margin-bottom: 4px;
`;
const NodeUnlocks = styled.div`
font-size: 10px;
color: ${t.text.muted};
line-height: 1.4;
`;
const NodeProgressTrack = styled.div`
height: 3px;
background: #ffffff18;
border-radius: 1px;
overflow: hidden;
margin-top: 5px;
`;
const NodeProgressFill = styled.div<{ $pct: number }>`
width: ${p => p.$pct}%;
height: 100%;
background: ${t.accent.science};
border-radius: 1px;
`;
/* Tooltip */
const Tooltip = styled.div`
position: fixed;
bottom: 20px; right: 20px;
width: 280px;
background: ${t.bg.panel};
border: 1px solid ${t.accent.gold};
border-radius: 4px;
z-index: 100;
overflow: hidden;
`;
const TtHeader = styled.div`
background: #2a1a06;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
`;
const TtIcon = styled.div`
font-size: 24px;
`;
const TtName = styled.div`
font-family: ${t.font.heading};
font-size: 18px;
color: ${t.text.title};
letter-spacing: 0.04em;
`;
const TtEra = styled.div`
font-size: 11px;
color: ${t.accent.gold};
`;
const TtBody = styled.div`
padding: 12px 14px;
`;
const TtRow = styled.div`
display: flex;
justify-content: space-between;
font-size: 12px;
color: ${t.text.secondary};
margin-bottom: 6px;
`;
const TtVal = styled.span<{ $color?: string }>`
color: ${p => p.$color ?? t.accent.science};
font-family: ${t.font.mono};
`;
const TtDivider = styled.hr`
border: none;
border-top: 1px solid ${t.border.divider};
margin: 8px 0;
`;
const TtUnlocksLabel = styled.div`
font-size: 11px;
color: ${t.text.muted};
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
`;
const TtUnlockItem = styled.div`
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: ${t.text.primary};
margin-bottom: 3px;
`;
const FILTERS = ["All", "Military", "Economy", "Industry"] as const;
export function TechTreePage(): React.ReactElement {
const [activeFilter, setActiveFilter] = useState<string>("All");
const [activePillar] = useState<Pillar>("Military");
return (
<PageWrap>
<Header>
<HeaderTitle>Technology Web</HeaderTitle>
<SciRate> +22 / turn</SciRate>
<CurrentResearch>
Researching: <ResearchName>Steel Working</ResearchName>
<ResearchTurns>· 4 turns</ResearchTurns>
</CurrentResearch>
<CloseBtn></CloseBtn>
</Header>
<PillarBar>
{PILLARS.map(p => (
<PillarLabel key={p} $active={p === activePillar}>{p}</PillarLabel>
))}
</PillarBar>
<FilterBar>
{FILTERS.map(f => (
<FilterChip key={f} $on={activeFilter === f} onClick={() => setActiveFilter(f)}>{f}</FilterChip>
))}
</FilterBar>
<TreeScroll>
<TreeCanvas>
{ERAS.map(e => (
<EraBand key={e.label} $top={e.y}>
<EraLabel>{e.label}</EraLabel>
</EraBand>
))}
<ConnectionsSvg>
{CONNECTIONS.map((c, i) => (
<line
key={i}
x1={c.x1} y1={c.y1} x2={c.x2} y2={c.y2}
stroke={c.stroke}
strokeWidth={c.width}
strokeDasharray={c.dash}
opacity={c.opacity}
>
{c.animated && (
<animate attributeName="stroke-dashoffset" values="0;-20" dur="1s" repeatCount="indefinite" />
)}
</line>
))}
</ConnectionsSvg>
{NODES.map(n => (
<NodeBox key={n.id} $status={n.status} $x={n.x} $y={n.y}>
<NodeHeader $status={n.status}>
<NodeIcon>{n.icon}</NodeIcon>
<NodeName $status={n.status}>{n.name}</NodeName>
</NodeHeader>
<NodeBody>
<NodeCost $researched={n.status === "researched"}>{n.cost}</NodeCost>
<NodeUnlocks>{n.unlocks}</NodeUnlocks>
{n.progress !== undefined && (
<NodeProgressTrack>
<NodeProgressFill $pct={n.progress} />
</NodeProgressTrack>
)}
</NodeBody>
</NodeBox>
))}
</TreeCanvas>
</TreeScroll>
<Tooltip>
<TtHeader>
<TtIcon></TtIcon>
<div>
<TtName>Steel Working</TtName>
<TtEra>Iron Age · Military + Industry</TtEra>
</div>
</TtHeader>
<TtBody>
<TtRow><span>Cost</span><TtVal>120 </TtVal></TtRow>
<TtRow><span>Progress</span><TtVal $color={t.accent.science}>88 / 120 · 4 turns</TtVal></TtRow>
<TtRow><span>Requires</span><TtVal $color={t.accent.gold}>Bronze Casting, Mining</TtVal></TtRow>
<TtDivider />
<TtUnlocksLabel>Unlocks</TtUnlocksLabel>
<TtUnlockItem><span></span> Iron Warrior (melee unit)</TtUnlockItem>
<TtUnlockItem><span>🔥</span> Forge upgrade (+2 )</TtUnlockItem>
<TtUnlockItem><span>🛡</span> Iron Shield (unit equipment)</TtUnlockItem>
<TtUnlockItem><span></span> Enables: Swordsmanship</TtUnlockItem>
</TtBody>
</Tooltip>
</PageWrap>
);
}