diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx index c83afb1..9e64cea 100644 --- a/components/character/character-creator.tsx +++ b/components/character/character-creator.tsx @@ -24,6 +24,7 @@ import { type ColorPreset, } from "@/lib/character/presets"; import type { LayerColorState, LayerVariantState } from "@/lib/character/types"; +import { isVariantUnlocked } from "@/lib/character/variant-access"; function SectionLabel({ children }: { children: React.ReactNode }) { return ( @@ -84,12 +85,16 @@ export function CharacterCreator({ const activeLayer = getLayerById(activeLayerId); const activeColor = colors[activeLayerId]; const ownedSet = new Set(ownedVariantIds); - const pieceVariants = activeLayer.allowVariants - ? activeLayer.variants.filter( + const pieceVariants = activeLayer.allowVariants ? activeLayer.variants : []; + const lockedVariantIds = new Set( + pieceVariants + .filter( (variant) => - variant.id === NONE_VARIANT_ID || ownedSet.has(variant.id), + variant.id !== NONE_VARIANT_ID && + !isVariantUnlocked(variant.id, ownedSet), ) - : []; + .map((variant) => variant.id), + ); const applyPreset = (preset: ColorPreset) => { setActivePresetId(preset.id); @@ -120,6 +125,10 @@ export function CharacterCreator({ }; const handleVariantChange = (variantId: string) => { + if (!isVariantUnlocked(variantId, ownedSet)) { + return; + } + setVariants((current) => ({ ...current, [activeLayerId]: variantId, @@ -189,6 +198,7 @@ export function CharacterCreator({
; activeId: string; color: HSL; skinColor?: HSL; @@ -19,6 +20,7 @@ type CharacterPieceSelectorProps = { export function CharacterPieceSelector({ variants, + lockedVariantIds, activeId, color, skinColor, @@ -66,19 +68,29 @@ export function CharacterPieceSelector({ ); } + const locked = lockedVariantIds?.has(variant.id) ?? false; + return ( ); })} diff --git a/lib/character/variant-access.ts b/lib/character/variant-access.ts new file mode 100644 index 0000000..016e716 --- /dev/null +++ b/lib/character/variant-access.ts @@ -0,0 +1,39 @@ +import { NONE_VARIANT_ID } from "@/lib/character/presets"; + +/** First N numbered styles per layer are free; higher numbers are shop-only. */ +export const FREE_STYLE_COUNT = 2; + +export function getVariantStyleNumber(variantId: string): number | null { + if (variantId === NONE_VARIANT_ID) { + return null; + } + + const match = variantId.match(/-(\d+)$/); + return match ? Number.parseInt(match[1], 10) : null; +} + +export function isFreeStyleVariant(variantId: string): boolean { + const styleNumber = getVariantStyleNumber(variantId); + return styleNumber !== null && styleNumber <= FREE_STYLE_COUNT; +} + +export function isShopOnlyStyleVariant(variantId: string): boolean { + const styleNumber = getVariantStyleNumber(variantId); + return styleNumber !== null && styleNumber > FREE_STYLE_COUNT; +} + +export function isVariantUnlocked( + variantId: string, + ownedVariantIds: ReadonlySet | readonly string[], +): boolean { + if (variantId === NONE_VARIANT_ID || isFreeStyleVariant(variantId)) { + return true; + } + + const owned = + ownedVariantIds instanceof Set + ? ownedVariantIds + : new Set(ownedVariantIds); + + return owned.has(variantId); +} diff --git a/lib/shop-catalog.ts b/lib/shop-catalog.ts index cc9d419..e0b6b67 100644 --- a/lib/shop-catalog.ts +++ b/lib/shop-catalog.ts @@ -1,3 +1,4 @@ +import { isShopOnlyStyleVariant } from "@/lib/character/variant-access"; import type { CharacterLayerId } from "@/lib/character/presets"; import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets"; @@ -57,7 +58,10 @@ export function getShopCatalogFromPresets(): ShopItemRecord[] { } return layer.variants - .filter((variant) => variant.id !== NONE_VARIANT_ID) + .filter( + (variant) => + variant.id !== NONE_VARIANT_ID && isShopOnlyStyleVariant(variant.id), + ) .map((variant) => ({ id: variant.id, name: variant.label, diff --git a/supabase/seed.sql b/supabase/seed.sql index dacd10e..8abfbe3 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -4,24 +4,28 @@ delete from public.user_items where item_id in ('hat_blue', 'glasses_round', 'room_sunset'); delete from public.shop_items -where id in ('hat_blue', 'glasses_round', 'room_sunset'); +where id in ( + 'hat_blue', + 'glasses_round', + 'room_sunset', + 'pants-1', + 'pants-2', + 'shoes-1', + 'shoes-2', + 'torso-1', + 'torso-2', + 'eyes-1', + 'eyes-2', + 'head-1', + 'head-2' +); insert into public.shop_items (id, name, type, price, image_path) values - ('pants-1', 'Pants 1', 'pants', 20, '/character/pants/pants-1.png'), - ('pants-2', 'Pants 2', 'pants', 25, '/character/pants/pants-2.png'), ('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'), - ('shoes-1', 'Shoes 1', 'shoes', 20, '/character/shoes/shoes-1.png'), - ('shoes-2', 'Shoes 2', 'shoes', 25, '/character/shoes/shoes-2.png'), ('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'), - ('torso-1', 'Torso 1', 'torso', 25, '/character/torso/torso-1.png'), - ('torso-2', 'Torso 2', 'torso', 30, '/character/torso/torso-2.png'), ('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'), ('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'), - ('eyes-1', 'Eyes 1', 'eyes', 15, '/character/eyes/eyes-1.png'), - ('eyes-2', 'Eyes 2', 'eyes', 20, '/character/eyes/eyes-2.png'), - ('head-1', 'Head 1', 'head', 30, '/character/head/head-1.png'), - ('head-2', 'Head 2', 'head', 35, '/character/head/head-2.png'), ('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'), ('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'), ('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'),