Files
Arcanum_TD/src/ui/screens/CampaignMap.tsx
T
Mareli 7a62067af1 Initial commit: Arcanum TD — medieval fantasy tower defense
Vite + React + PixiJS + TypeScript. Features:
- 4-level campaign (King's Road → Obsidian Keep)
- Isometric 2.5D grid with ley-line mechanics
- ECS architecture (entities, components, systems)
- 4 tower types, hero spellcaster, 10+ enemy types
- Lich King boss with 3-phase AI
- Meta-progression: essence, rune unlocks
- Full UI redesign with fantasy design system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 12:31:49 +03:00

219 lines
8.1 KiB
TypeScript

import { useGameStore } from '@/state/gameStore'
import { useMetaStore } from '@/state/metaStore'
interface Level {
id: string
name: string
nameRu: string
waves: number
difficulty: 'Новобранец' | 'Рыцарь' | 'Архимаг' | 'Босс'
unlockedBy: string | null
x: string
y: string
biomeColor: string
biomeGlow: string
icon: string
}
const LEVELS: Level[] = [
{
id: 'kings_road',
name: "King's Road",
nameRu: 'Королевский Тракт',
waves: 20,
difficulty: 'Новобранец',
unlockedBy: null,
x: '18%', y: '62%',
biomeColor: 'border-emerald-500/60',
biomeGlow: 'rgba(74,222,128,0.2)',
icon: '⚔',
},
{
id: 'whispering_woods',
name: 'Whispering Woods',
nameRu: 'Шепчущий Лес',
waves: 20,
difficulty: 'Рыцарь',
unlockedBy: 'kings_road',
x: '46%', y: '36%',
biomeColor: 'border-gold/60',
biomeGlow: 'rgba(201,161,74,0.2)',
icon: '🌲',
},
{
id: 'frostfall_pass',
name: 'Frostfall Pass',
nameRu: 'Морозный Перевал',
waves: 20,
difficulty: 'Архимаг',
unlockedBy: 'whispering_woods',
x: '70%', y: '22%',
biomeColor: 'border-frost/60',
biomeGlow: 'rgba(110,203,213,0.2)',
icon: '❄',
},
{
id: 'obsidian_keep',
name: 'Obsidian Keep',
nameRu: 'Обсидиановая Цитадель',
waves: 10,
difficulty: 'Босс',
unlockedBy: 'frostfall_pass',
x: '84%', y: '56%',
biomeColor: 'border-arcane/60',
biomeGlow: 'rgba(155,77,232,0.22)',
icon: '💀',
},
]
const difficultyStyle: Record<string, string> = {
'Новобранец': 'text-emerald-400',
'Рыцарь': 'text-gold',
'Архимаг': 'text-rose-400',
'Босс': 'text-arcane',
}
export function CampaignMap() {
const setScreen = useGameStore((s) => s.setScreen)
const resetGame = useGameStore((s) => s.resetGame)
const setCurrentLevelId = useGameStore((s) => s.setCurrentLevelId)
const completedLevels = useMetaStore((s) => s.completedLevels)
const isUnlocked = (level: Level) =>
level.unlockedBy === null || completedLevels.includes(level.unlockedBy)
const handlePlay = (levelId: string) => {
setCurrentLevelId(levelId)
resetGame()
setScreen('game')
}
return (
<div className="w-full h-full flex flex-col bg-midnight-deep overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-8 py-4 border-b border-gold/15 bg-midnight/60 backdrop-blur-sm shrink-0">
<button
onClick={() => setScreen('menu')}
className="btn-ghost text-xs py-2 px-4"
>
Назад
</button>
<div className="flex flex-col items-center">
<h2 className="text-title text-xl tracking-[0.3em]">Карта Кампании</h2>
<p className="font-garamond text-parchment/35 text-xs mt-0.5 tracking-wider">
{completedLevels.length} / {LEVELS.length} уровней завершено
</p>
</div>
<div className="w-24" />
</div>
{/* Map area */}
<div className="relative flex-1 w-full overflow-hidden">
{/* Background atmosphere */}
<div className="absolute inset-0"
style={{ background: 'radial-gradient(ellipse 70% 60% at 50% 40%, #111828 0%, #070b14 100%)' }} />
<div className="absolute inset-0 opacity-30"
style={{ backgroundImage: 'radial-gradient(circle at 20% 65%, rgba(74,222,128,0.08) 0%, transparent 35%), radial-gradient(circle at 47% 38%, rgba(201,161,74,0.07) 0%, transparent 30%), radial-gradient(circle at 70% 22%, rgba(110,203,213,0.07) 0%, transparent 25%), radial-gradient(circle at 84% 55%, rgba(155,77,232,0.1) 0%, transparent 30%)' }} />
{/* SVG connection lines */}
<svg className="absolute inset-0 w-full h-full pointer-events-none" aria-hidden>
<defs>
<filter id="glow-line">
<feGaussianBlur stdDeviation="2" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<line x1="18%" y1="62%" x2="46%" y2="36%" stroke="rgba(201,161,74,0.18)" strokeWidth="2" strokeDasharray="8 5" />
<line x1="46%" y1="36%" x2="70%" y2="22%" stroke="rgba(201,161,74,0.18)" strokeWidth="2" strokeDasharray="8 5" />
<line x1="70%" y1="22%" x2="84%" y2="56%" stroke="rgba(155,77,232,0.15)" strokeWidth="2" strokeDasharray="6 5" />
</svg>
{/* Level nodes */}
{LEVELS.map((level) => {
const completed = completedLevels.includes(level.id)
const unlocked = isUnlocked(level)
return (
<div
key={level.id}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: level.x, top: level.y }}
>
<div className="flex flex-col items-center gap-2.5">
{/* Node button */}
<button
onClick={unlocked ? () => handlePlay(level.id) : undefined}
disabled={!unlocked}
className={`
relative w-16 h-16 rounded-full border-2 flex items-center justify-center
transition-all duration-300 select-none
${!unlocked
? 'border-parchment/15 bg-midnight/60 cursor-not-allowed'
: completed
? `${level.biomeColor} bg-midnight hover:scale-110`
: `${level.biomeColor} bg-midnight hover:scale-110`
}
`}
style={unlocked ? { boxShadow: `0 0 18px ${level.biomeGlow}, 0 0 40px ${level.biomeGlow}` } : undefined}
>
<span className="text-2xl">
{!unlocked ? '🔒' : completed ? '✦' : level.icon}
</span>
{unlocked && !completed && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-ember rounded-full animate-pulse" />
)}
{completed && (
<span
className="absolute inset-0 rounded-full"
style={{ boxShadow: `inset 0 0 16px ${level.biomeGlow}` }}
/>
)}
</button>
{/* Label card */}
<div
className={`panel-parchment px-3 py-2 text-center min-w-[148px] transition-all duration-300 ${
unlocked ? 'opacity-100' : 'opacity-45'
}`}
>
<p className="font-cinzel text-[11px] text-gold font-bold tracking-wide leading-none">
{level.name}
</p>
<p className="font-garamond text-[11px] text-parchment/60 mt-0.5 leading-none">
{level.nameRu}
</p>
<div className="rune-divider my-1.5" />
<div className="flex items-center justify-center gap-2">
<span className={`font-cinzel text-[10px] font-bold ${difficultyStyle[level.difficulty]}`}>
{level.difficulty}
</span>
<span className="text-parchment/25 text-[10px]">·</span>
<span className="font-garamond text-[11px] text-parchment/45">
{level.waves} волн
</span>
</div>
{completed && (
<p className="font-cinzel text-[9px] text-gold/60 mt-1 tracking-widest uppercase">
Пройдено
</p>
)}
</div>
</div>
</div>
)
})}
{/* Bottom lore */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
<p className="font-garamond text-parchment/20 text-xs italic tracking-wider text-center">
«Тьма не знает покоя каждый рубеж должен устоять»
</p>
</div>
</div>
</div>
)
}