feat(@projects/@magic-civilization): ✨ add promotion picker page
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
7db83c3519
commit
2364c10c9c
2 changed files with 920 additions and 0 deletions
406
.project/designs/app/src/pages/PromotionPicker.tsx
Normal file
406
.project/designs/app/src/pages/PromotionPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
514
.project/designs/app/src/pages/TechTree.tsx
Normal file
514
.project/designs/app/src/pages/TechTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue