diff --git a/app/customizations/CharacterCosmetics.tsx b/app/customizations/CharacterCosmetics.tsx new file mode 100644 index 0000000..57ab216 --- /dev/null +++ b/app/customizations/CharacterCosmetics.tsx @@ -0,0 +1,73 @@ +'use client'; + +import shared from '../../styles/shared.module.css'; +import styles from '../../styles/Customizations.module.css'; +import { CustomizationItem, CATEGORY_LABELS, CATEGORY_ORDER, getCosmeticIconUrl } from './types'; + +type Props = { + charName: string; + charCosmetics: Record; + unlockedSet: Set; + characterMap: Map; + onBack: () => void; + onUnlockAll: () => void; + onLockAll: () => void; + onToggle: (id: string) => void; +}; + +export default function CharacterCosmetics({ + charName, + charCosmetics, + unlockedSet, + characterMap, + onBack, + onUnlockAll, + onLockAll, + onToggle, +}: Props) { + const allItems = Object.values(charCosmetics).flat(); + const unlockedCount = allItems.filter(i => unlockedSet.has(i.id)).length; + + return ( +
+
+ + {charName} + {unlockedCount} / {allItems.length} unlocked + + + +
+ +
+ {CATEGORY_ORDER.filter(cat => charCosmetics[cat]?.length > 0).map(cat => ( +
+
+ {CATEGORY_LABELS[cat] ?? `Category ${cat}`} +
+
+ {charCosmetics[cat].map(item => { + const unlocked = unlockedSet.has(item.id); + return ( +
onToggle(item.id)} + > + {item.name} + {item.name} +
+ ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/app/customizations/CharacterPicker.tsx b/app/customizations/CharacterPicker.tsx new file mode 100644 index 0000000..2014323 --- /dev/null +++ b/app/customizations/CharacterPicker.tsx @@ -0,0 +1,88 @@ +'use client'; + +import shared from '../../styles/shared.module.css'; +import styles from '../../styles/Customizations.module.css'; +import { Character, RoleFilter } from './types'; + +type Props = { + filteredChars: Character[]; + charSearch: string; + charRole: RoleFilter; + charFullyUnlocked: Map; + onSelect: (idx: number) => void; + onSearchChange: (s: string) => void; + onRoleChange: (r: RoleFilter) => void; + onUnlockShownCosmetics: () => void; + onLockShownCosmetics: () => void; +}; + +const getCharIconUrl = (iconFilePath: string) => { + const file = (iconFilePath.split('/').pop() ?? '').split('.')[0]; + return `/icons/character-icons/${file}.png`; +}; + +export default function CharacterPicker({ + filteredChars, + charSearch, + charRole, + charFullyUnlocked, + onSelect, + onSearchChange, + onRoleChange, + onUnlockShownCosmetics, + onLockShownCosmetics, +}: Props) { + return ( + <> +
+ onSearchChange(e.target.value)} + /> +
+ {(['all', 'survivors', 'killers'] as RoleFilter[]).map(r => ( + + ))} +
+ + {filteredChars.length} shown + + +
+ +
+ {filteredChars.map(char => { + const fullyUnlocked = charFullyUnlocked.get(char.idx) ?? false; + return ( +
onSelect(char.idx)} + > + {char.name} + {char.name} +
+ ); + })} +
+ + ); +} diff --git a/app/customizations/FlatCategory.tsx b/app/customizations/FlatCategory.tsx new file mode 100644 index 0000000..aae3f86 --- /dev/null +++ b/app/customizations/FlatCategory.tsx @@ -0,0 +1,136 @@ +'use client'; + +import shared from '../../styles/shared.module.css'; +import styles from '../../styles/Customizations.module.css'; +import { CustomizationItem, Tab, getCosmeticIconUrl } from './types'; + +type Props = { + tab: Tab; + allFilteredItems: CustomizationItem[]; + pagedItems: CustomizationItem[]; + page: number; + totalPages: number; + search: string; + unlockedSet: Set; + characterMap: Map; + onSearchChange: (s: string) => void; + onUnlockAll: () => void; + onLockAll: () => void; + onUnlockPage: () => void; + onLockPage: () => void; + onToggle: (id: string) => void; + onPageChange: (p: number) => void; +}; + +export default function FlatCategory({ + tab, + allFilteredItems, + pagedItems, + page, + totalPages, + search, + unlockedSet, + characterMap, + onSearchChange, + onUnlockAll, + onLockAll, + onUnlockPage, + onLockPage, + onToggle, + onPageChange, +}: Props) { + const buildPageNumbers = () => { + const pages: number[] = []; + const start = Math.max(1, Math.min(page - 2, totalPages - 4)); + const end = Math.min(totalPages, start + 4); + for (let i = start; i <= end; i++) pages.push(i); + return pages; + }; + + return ( + <> +
+ onSearchChange(e.target.value)} + /> + + {allFilteredItems.length} items + + + + +
+ + {allFilteredItems.length === 0 ? ( +
No items found
+ ) : ( + <> +
+ {pagedItems.map(item => { + const unlocked = unlockedSet.has(item.id); + return ( +
onToggle(item.id)} + > + {item.name} + {item.name} +
+ ); + })} +
+ + {totalPages > 1 && ( +
+ + + + {buildPageNumbers().map(n => ( + + ))} + + + + + {page} / {totalPages} +
+ )} + + )} + + ); +} \ No newline at end of file diff --git a/app/customizations/page.tsx b/app/customizations/page.tsx new file mode 100644 index 0000000..9d46619 --- /dev/null +++ b/app/customizations/page.tsx @@ -0,0 +1,228 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { useInventoryStore } from '@/store/useInventoryStore'; + +import shared from '../../styles/shared.module.css'; +import styles from '../../styles/Customizations.module.css'; + +import { getFileName, cleanFolderName, isKiller } from '../lib/utils'; +import { Character, CustomizationItem, RoleFilter, Tab, TAB_CATEGORIES, CATEGORY_ORDER } from './types'; +import CharacterPicker from './CharacterPicker'; +import CharacterCosmetics from './CharacterCosmetics'; +import FlatCategory from './FlatCategory'; + +/* + constants +*/ +const PAGE_SIZE = 60; + + +export default function CustomizationsPage() { + const store = useInventoryStore(); + + const [allItems, setAllItems] = useState([]); + const [characters, setCharacters] = useState([]); + const [tab, setTab] = useState('cosmetics'); + const [selectedChar, setSelectedChar] = useState(null); + const [charSearch, setCharSearch] = useState(''); + const [charRole, setCharRole] = useState('all'); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + + useEffect(() => { + Promise.all([ + fetch('/data/customization_items.json').then(r => r.json()).catch(() => []), + fetch('/data/characters.json').then(r => r.json()).catch(() => []), + ]).then(([items, chars]) => { + setAllItems(items); + setCharacters(chars); + }); + }, []); + + useEffect(() => { setPage(1); }, [tab, search]); + + /* + derived data + */ + const characterMap = useMemo(() => { + const m = new Map(); + characters.forEach(c => m.set(c.idx, c.name)); + return m; + }, [characters]); + + const unlockedSet = useMemo( + () => new Set(store.unlockedCustomizations), + [store.unlockedCustomizations] + ); + + const itemsByCharacter = useMemo(() => { + const map = new Map(); + allItems.forEach(item => { + if (item.category >= 1 && item.category <= 7 && item.associatedCharacter > -1) { + if (!map.has(item.associatedCharacter)) map.set(item.associatedCharacter, []); + map.get(item.associatedCharacter)!.push(item); + } + }); + return map; + }, [allItems]); + + const charFullyUnlocked = useMemo(() => { + const result = new Map(); + characters.forEach(char => { + const items = itemsByCharacter.get(char.idx) ?? []; + result.set(char.idx, items.length > 0 && items.every(i => unlockedSet.has(i.id))); + }); + return result; + }, [characters, itemsByCharacter, unlockedSet]); + + const filteredChars = useMemo(() => { + return characters.filter(c => { + if (charRole === 'survivors' && isKiller(c.idx)) return false; + if (charRole === 'killers' && !isKiller(c.idx)) return false; + if (charSearch.trim() && !c.name.toLowerCase().includes(charSearch.toLowerCase())) return false; + return true; + }); + }, [characters, charRole, charSearch]); + + const flatItems = useMemo(() => { + if (tab === 'cosmetics') return []; + const cat = TAB_CATEGORIES[tab]; + if (cat === undefined) return []; + return allItems.filter(item => { + if (item.category !== cat) return false; + if (search.trim() && !item.name.toLowerCase().includes(search.toLowerCase())) return false; + return true; + }); + }, [allItems, tab, search]); + + const totalPages = Math.max(1, Math.ceil(flatItems.length / PAGE_SIZE)); + const pagedItems = useMemo( + () => flatItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), + [flatItems, page] + ); + + const charCosmetics = useMemo(() => { + if (selectedChar === null) return {}; + const items = itemsByCharacter.get(selectedChar) ?? []; + const groups: Record = {}; + CATEGORY_ORDER.forEach(cat => { + const group = items.filter(i => i.category === cat); + if (group.length > 0) groups[cat] = group; + }); + return groups; + }, [itemsByCharacter, selectedChar]); + + /* + lock/unlock helpers + */ + const mergeUnlock = (ids: string[]) => { + const merged = Array.from(new Set([...store.unlockedCustomizations, ...ids])); + store.unlockAllInCategory('customizations', merged); + }; + + const removeLock = (ids: string[]) => { + const toRemove = new Set(ids); + const remaining = store.unlockedCustomizations.filter(id => !toRemove.has(id)); + store.unlockAllInCategory('customizations', remaining); + }; + + /* + handlers + */ + const handleToggle = (id: string) => store.toggleItem(id, 'customizations'); + + const handleUnlockShownCosmetics = () => { + const charSet = new Set(filteredChars.map(c => c.idx)); + mergeUnlock( + allItems.filter(i => charSet.has(i.associatedCharacter) && i.category >= 1 && i.category <= 7).map(i => i.id) + ); + }; + + const handleLockShownCosmetics = () => { + const charSet = new Set(filteredChars.map(c => c.idx)); + removeLock( + allItems.filter(i => charSet.has(i.associatedCharacter) && i.category >= 1 && i.category <= 7).map(i => i.id) + ); + }; + + const handleUnlockCharCosmetics = () => mergeUnlock(Object.values(charCosmetics).flat().map(i => i.id)); + const handleLockCharCosmetics = () => removeLock(Object.values(charCosmetics).flat().map(i => i.id)); + + const handleUnlockAll = () => mergeUnlock(flatItems.map(i => i.id)); + const handleLockAll = () => removeLock(flatItems.map(i => i.id)); + + const handleUnlockPage = () => mergeUnlock(pagedItems.map(i => i.id)); + const handleLockPage = () => removeLock(pagedItems.map(i => i.id)); + + return ( +
+
+
+

Customizations

+

+ {store.unlockedCustomizations.length} of {allItems.length || '-'} unlocked +

+
+ +
+
+ {(['cosmetics', 'charms', 'badges', 'banners', 'portraits'] as Tab[]).map(t => ( + + ))} +
+ {tab === 'cosmetics' && selectedChar === null && ( + + )} + {tab === 'cosmetics' && selectedChar !== null && ( + setSelectedChar(null)} + onUnlockAll={handleUnlockCharCosmetics} + onLockAll={handleLockCharCosmetics} + onToggle={handleToggle} + /> + )} + {tab !== 'cosmetics' && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/app/customizations/types.ts b/app/customizations/types.ts new file mode 100644 index 0000000..84b1335 --- /dev/null +++ b/app/customizations/types.ts @@ -0,0 +1,54 @@ +export type CustomizationItem = { + id: string; + name: string; + iconFilePath: string; + category: number; + associatedCharacter: number; +}; + +export type Character = { + idx: number; + name: string; + iconFilePath: string; +}; + +export type Tab = 'cosmetics' | 'charms' | 'badges' | 'banners' | 'portraits'; +export type RoleFilter = 'all' | 'survivors' | 'killers'; + +export const CATEGORY_LABELS: Record = { + 1: 'Heads', 2: 'Torsos', 3: 'Legs', + 4: 'Heads', 5: 'Bodies', 6: 'Weapons', 7: 'Outfits', +}; + +export const CATEGORY_ORDER = [7, 1, 4, 2, 3, 5, 6]; + +export const TAB_CATEGORIES: Partial> = { + charms: 8, badges: 9, banners: 10, portraits: 11, +}; + +export const getCosmeticIconUrl = ( + item: CustomizationItem, + characterMap: Map +): string => { + const file = (item.iconFilePath.split('/').pop() ?? '').split('.')[0]; + + switch (item.category) { + case 8: return `/icons/customization/charms/${file}.png`; + case 9: return `/icons/customization/badges/${file}.png`; + case 10: return `/icons/customization/banners/${file}.png`; + case 11: return `/icons/customization/portrait-backgrounds/${file}.png`; + } + + const subfolder = + item.category === 1 || item.category === 4 ? 'heads' : + item.category === 2 ? 'torsos' : + item.category === 3 ? 'legs' : + item.category === 5 ? 'bodys' : + item.category === 6 ? 'weapons' : 'outfits'; + + const charName = characterMap.get(item.associatedCharacter); + const charFolder = (charName ?? item.associatedCharacter.toString()) + .replace(/[\\/:*?"<>|]/g, '_'); + + return `/icons/customization/characters/${charFolder}/${subfolder}/${file}.png`; +}; \ No newline at end of file diff --git a/styles/Customizations.module.css b/styles/Customizations.module.css new file mode 100644 index 0000000..79df29c --- /dev/null +++ b/styles/Customizations.module.css @@ -0,0 +1,208 @@ +/* + tabs +*/ +.tabs { + display: flex; + gap: 0; + border-bottom: 1px solid #1a1a1a; + margin-bottom: 1.5rem; + flex-shrink: 0; +} + +.tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + color: #444444; + padding: 0.7rem 1.4rem; + font-family: 'Oswald', sans-serif; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + transition: all 0.15s ease; +} + +.tab:hover { + color: #888888; +} + +.tabActive { + color: #ffffff; + border-bottom-color: #a30000; +} + +/* + page btns +*/ +.pageActionBtn { + background: transparent; + border: 1px solid #1e1e1e; + color: #3a3a3a; + padding: 0.6rem 1rem; + font-family: 'Oswald', sans-serif; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + transition: all 0.15s ease; +} + +.pageActionBtn:hover { + color: #666666; + border-color: #2e2e2e; +} + +/* + char picker +*/ +.charGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 0.75rem; + overflow-y: auto; + flex: 1; + align-content: start; + padding-right: 0.5rem; +} + +.charCard { + background: #0d0d0d; + border: 1px solid #1a1a1a; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 0.5rem 0.6rem; + gap: 0.4rem; + cursor: pointer; + transition: all 0.15s ease; + user-select: none; +} + +.charCard:hover { + border-color: #333333; + background: #111111; +} + +.charCardSelected { + border-color: #a30000; + background: #0d0000; +} + +.charCardIcon { + width: 56px; + height: 56px; + object-fit: contain; +} + +.charCardName { + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: center; + color: #555555; + line-height: 1.3; +} + +.charCardSelected .charCardName { + color: #c9c9c9; +} + +.charCardFullyUnlocked { + border-color: #6b4c00; + background: #0d0900; +} + +.charCardFullyUnlocked:hover { + border-color: #b37a00; + box-shadow: 0 0 18px rgba(160, 110, 0, 0.3); +} + +.charCardFullyUnlocked .charCardName { + color: #9a7020; +} + +/* + sel view +*/ +.cosmeticsView { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.cosmeticsHeader { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 1.5rem; + flex-shrink: 0; +} + +.backBtn { + background: transparent; + border: 1px solid #1a1a1a; + color: #555555; + padding: 0.5rem 1rem; + font-family: 'Oswald', sans-serif; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + transition: all 0.15s ease; +} + +.backBtn:hover { + color: #ffffff; + border-color: #333333; +} + +.cosmeticsCharName { + font-family: 'Oswald', sans-serif; + font-size: 1.25rem; + color: #ffffff; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.cosmeticsBody { + overflow-y: auto; + flex: 1; + padding-right: 0.5rem; +} + +.categoryGroup { + margin-bottom: 2.5rem; +} + +.categoryTitle { + font-family: 'Oswald', sans-serif; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #444444; + border-bottom: 1px solid #1a1a1a; + padding-bottom: 0.5rem; + margin-bottom: 1rem; +} + +/* + grid +*/ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 0.75rem; + overflow-y: auto; + flex: 1; + align-content: start; + padding-right: 0.5rem; +} + +.gridInline { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 0.75rem; +} \ No newline at end of file