228 lines
8.8 KiB
TypeScript
228 lines
8.8 KiB
TypeScript
'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<CustomizationItem[]>([]);
|
|
const [characters, setCharacters] = useState<Character[]>([]);
|
|
const [tab, setTab] = useState<Tab>('cosmetics');
|
|
const [selectedChar, setSelectedChar] = useState<number | null>(null);
|
|
const [charSearch, setCharSearch] = useState('');
|
|
const [charRole, setCharRole] = useState<RoleFilter>('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<number, string>();
|
|
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<number, CustomizationItem[]>();
|
|
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<number, boolean>();
|
|
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<number, CustomizationItem[]> = {};
|
|
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 (
|
|
<div className={shared.container}>
|
|
<header className={shared.header}>
|
|
<div>
|
|
<h1 className={shared.title}>Customizations</h1>
|
|
<p className={shared.subtitle}>
|
|
{store.unlockedCustomizations.length} of {allItems.length || '-'} unlocked
|
|
</p>
|
|
</div>
|
|
<button className={shared.clearBtn} onClick={() => store.clearCategory('customizations')}>
|
|
Clear All
|
|
</button>
|
|
</header>
|
|
<div className={styles.tabs}>
|
|
{(['cosmetics', 'charms', 'badges', 'banners', 'portraits'] as Tab[]).map(t => (
|
|
<button
|
|
key={t}
|
|
className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`}
|
|
onClick={() => { setTab(t); setSelectedChar(null); }}
|
|
>
|
|
{t === 'cosmetics' ? 'Character Cosmetics' : t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{tab === 'cosmetics' && selectedChar === null && (
|
|
<CharacterPicker
|
|
filteredChars={filteredChars}
|
|
charSearch={charSearch}
|
|
charRole={charRole}
|
|
charFullyUnlocked={charFullyUnlocked}
|
|
onSelect={setSelectedChar}
|
|
onSearchChange={setCharSearch}
|
|
onRoleChange={setCharRole}
|
|
onUnlockShownCosmetics={handleUnlockShownCosmetics}
|
|
onLockShownCosmetics={handleLockShownCosmetics}
|
|
/>
|
|
)}
|
|
{tab === 'cosmetics' && selectedChar !== null && (
|
|
<CharacterCosmetics
|
|
charName={characterMap.get(selectedChar) ?? selectedChar.toString()}
|
|
charCosmetics={charCosmetics}
|
|
unlockedSet={unlockedSet}
|
|
characterMap={characterMap}
|
|
onBack={() => setSelectedChar(null)}
|
|
onUnlockAll={handleUnlockCharCosmetics}
|
|
onLockAll={handleLockCharCosmetics}
|
|
onToggle={handleToggle}
|
|
/>
|
|
)}
|
|
{tab !== 'cosmetics' && (
|
|
<FlatCategory
|
|
tab={tab}
|
|
allFilteredItems={flatItems}
|
|
pagedItems={pagedItems}
|
|
page={page}
|
|
totalPages={totalPages}
|
|
search={search}
|
|
unlockedSet={unlockedSet}
|
|
characterMap={characterMap}
|
|
onSearchChange={setSearch}
|
|
onUnlockAll={handleUnlockAll}
|
|
onLockAll={handleLockAll}
|
|
onUnlockPage={handleUnlockPage}
|
|
onLockPage={handleLockPage}
|
|
onToggle={handleToggle}
|
|
onPageChange={setPage}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
} |