chuni: add userbox viewing

This commit is contained in:
sk1982 2024-03-17 19:15:16 -04:00
parent 1be321bdaa
commit a41df38a28
10 changed files with 518 additions and 1 deletions

View File

@ -0,0 +1,109 @@
'use server';
import { UserPayload } from '@/types/user';
import { db, GeneratedDB } from '@/db';
import { ExpressionBuilder, SelectQueryBuilder } from 'kysely';
import { ItemKind } from '@/helpers/chuni/items';
import { jsonObjectArray } from '@/types/json-object-array';
import { AvatarCategory } from '@/helpers/chuni/avatar';
import { DB } from '@/types/db';
import { ChuniUserData } from '@/actions/chuni/profile';
const ALLOW_EQUIP_UNEARNED = ['true', '1', 'yes'].includes(process.env.CHUNI_ALLOW_EQUIP_UNEARNED?.toLowerCase() ?? '');
const joinItem = <DB extends GeneratedDB, TB extends keyof DB, O>(builder: SelectQueryBuilder<DB, TB, O>, joinKey: any, user: number, itemKind: ItemKind, ...equipped: (number | null | undefined)[]) => {
return (builder.leftJoin('chuni_item_item', join =>
join.onRef('chuni_item_item.itemId' as any, '=', joinKey)
.on('chuni_item_item.user' as any, '=', user)
.on('chuni_item_item.itemKind' as any, '=', itemKind)
) as any)
.where((eb: ExpressionBuilder<any, any>) => eb.or(ALLOW_EQUIP_UNEARNED ? [eb.lit(true)] : [
eb('chuni_item_item.itemId', 'is not', null), // owned item
...equipped.map(id => eb(joinKey, '=', id ?? -1)) // equipped but not owned
])) as SelectQueryBuilder<DB, TB, O>;
};
type ImageKeys = 'id' | 'name' | 'sortName' | 'imagePath';
export type UserboxItems = {
mapIcon: Pick<DB['actaeon_chuni_static_map_icon'], ImageKeys>[],
namePlate: Pick<DB['actaeon_chuni_static_name_plate'], ImageKeys>[],
systemVoice: Pick<DB['actaeon_chuni_static_system_voice'], ImageKeys | 'cuePath'>[],
trophy: Pick<DB['actaeon_chuni_static_trophies'], 'id' | 'name' | 'rareType' | 'explainText'>[]
} & {
[K in `avatar${'Wear' | 'Head' | 'Face' | 'Skin' | 'Item' | 'Front' | 'Back'}`]:
Pick<DB['chuni_static_avatar'], 'avatarAccessoryId' | 'name' | 'iconPath' | 'texturePath'>[]
};
export const getUserboxItems = async (user: UserPayload, profile: ChuniUserData): Promise<UserboxItems> => {
const res = await db
.with('map_icons', eb => joinItem(eb.selectFrom('actaeon_chuni_static_map_icon as map_icon'),
'map_icon.id', user.id, ItemKind.MAP_ICON, profile?.mapIconId)
.select(eb => jsonObjectArray(
eb.ref('map_icon.id'),
eb.ref('map_icon.name'),
eb.ref('map_icon.sortName'),
eb.ref('map_icon.imagePath')
).as('mapIcon'))
)
.with('name_plates', eb => joinItem(eb.selectFrom('actaeon_chuni_static_name_plate as name_plate'),
'name_plate.id', user.id, ItemKind.NAME_PLATE, profile?.nameplateId)
.select(eb => jsonObjectArray(
eb.ref('name_plate.id'),
eb.ref('name_plate.name'),
eb.ref('name_plate.sortName'),
eb.ref('name_plate.imagePath')
).as('namePlate')))
.with('system_voices', eb => joinItem(eb.selectFrom('actaeon_chuni_static_system_voice as system_voice'),
'system_voice.id', user.id, ItemKind.SYSTEM_VOICE, profile?.voiceId)
.select(eb => jsonObjectArray(
eb.ref('system_voice.id'),
eb.ref('system_voice.name'),
eb.ref('system_voice.sortName'),
eb.ref('system_voice.imagePath'),
eb.ref('system_voice.cuePath'),
).as('systemVoice')))
.with('trophies', eb => joinItem(eb.selectFrom('actaeon_chuni_static_trophies as trophy'),
'trophy.id', user.id, ItemKind.TROPHY, profile?.nameplateId)
.select(eb => jsonObjectArray(
eb.ref('trophy.id'),
eb.ref('trophy.name'),
eb.ref('trophy.rareType'),
eb.ref('trophy.explainText')
).as('trophy')))
.with('avatars', eb => joinItem(eb.selectFrom('chuni_static_avatar as avatar'),
'avatar.avatarAccessoryId', user.id, ItemKind.AVATAR_ACCESSORY, profile?.avatarBack, profile?.avatarFace, profile?.avatarItem,
profile?.avatarWear, profile?.avatarFront, profile?.avatarSkin, profile?.avatarHead)
.where(({ selectFrom, eb }) => eb('avatar.version', '=', selectFrom('chuni_static_avatar')
.select(({ fn }) => fn.max('version').as('latest'))))
.groupBy('avatar.category')
.select(eb => ['avatar.category', jsonObjectArray(
eb.ref('avatar.avatarAccessoryId').as('id'),
eb.ref('avatar.name'),
eb.ref('avatar.iconPath'),
eb.ref('avatar.texturePath')
).as('avatar')]))
.selectFrom(['map_icons', 'name_plates', 'system_voices', 'trophies', 'avatars'])
.select(eb => ['map_icons.mapIcon', 'name_plates.namePlate', 'system_voices.systemVoice', 'trophies.trophy',
jsonObjectArray(eb.ref('avatars.category'), eb.ref('avatars.avatar')).as('avatar')])
.executeTakeFirstOrThrow();
const data = Object.fromEntries(Object.entries(res)
.map(([key, val]) => [key, JSON.parse(val)]));
const { avatar, ...output } = data;
const itemTypes: { [key: number]: any[] } = {};
Object.entries(AvatarCategory).forEach(([category, number]) => {
const key = `avatar${category[0]}${category.slice(1).toLowerCase()}`;
output[key] = itemTypes[number] = [];
});
(avatar as { category: number, avatar: UserboxItems['avatarBack'] }[])
?.forEach(({ category, avatar }) => itemTypes[category].push(...avatar));
output.mapIcon ??= [];
output.namePlate ??= [];
output.systemVoice ??= [];
output.trophy ??= [];
return output as any;
};

View File

@ -0,0 +1,19 @@
import { requireUser } from '@/actions/auth';
import { getUserData } from '@/actions/chuni/profile';
import { getUserboxItems } from '@/actions/chuni/userbox';
import { ChuniUserbox } from '@/components/chuni/userbox';
import { Viewport } from 'next';
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
interactiveWidget: 'resizes-content'
};
export default async function ChuniUserboxPage() {
const user = await requireUser();
const profile = await getUserData(user);
const userboxItems = await getUserboxItems(user, profile);
return (<ChuniUserbox profile={profile} userboxItems={userboxItems} />);
}

View File

@ -0,0 +1,302 @@
'use client';
import { ChuniUserData, getUserData } from '@/actions/chuni/profile';
import { UserboxItems } from '@/actions/chuni/userbox';
import { ChuniNameplate } from '@/components/chuni/nameplate';
import { avatar, Button, ButtonGroup, Checkbox, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Select, SelectItem, user } from '@nextui-org/react';
import { SelectModalButton } from '@/components/select-modal';
import { ChuniTrophy } from '@/components/chuni/trophy';
import { getAudioUrl, getImageUrl } from '@/helpers/assets';
import { useEffect, useRef, useState } from 'react';
import { ChuniAvatar } from '@/components/chuni/avatar';
import { CHUNI_VOICE_LINES } from '@/helpers/chuni/voice';
import { PlayIcon, StopIcon } from '@heroicons/react/24/solid';
import { SaveIcon } from '@/components/save-icon';
export type ChuniUserboxProps = {
profile: ChuniUserData,
userboxItems: UserboxItems
};
const ITEM_KEYS: Record<keyof UserboxItems, keyof NonNullable<ChuniUserData>> = {
namePlate: 'nameplateId',
trophy: 'trophyId',
mapIcon: 'mapIconId',
systemVoice: 'voiceId',
avatarWear: 'avatarWear',
avatarHead: 'avatarHead',
avatarFace: 'avatarFace',
avatarSkin: 'avatarSkin',
avatarItem: 'avatarItem',
avatarFront: 'avatarFront',
avatarBack: 'avatarBack'
};
const AVATAR_KEYS = ['avatarWear', 'avatarHead', 'avatarFace', 'avatarSkin', 'avatarItem', 'avatarFront', 'avatarBack'] as const;
type RequiredUserbox = NonNullable<UserboxItems>;
type EquippedItem = { [K in keyof RequiredUserbox]: RequiredUserbox[K][number] };
type SavedItem = { [K in keyof RequiredUserbox]: boolean };
export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
const initialEquipped = useRef(Object.fromEntries(Object.entries(ITEM_KEYS)
.map(([key, profileKey]) => [key, userboxItems[key as keyof RequiredUserbox]
.find(i => ('id' in i ? i.id : i.avatarAccessoryId) === profile?.[profileKey])])) as EquippedItem);
const [equipped, setEquipped] = useState<EquippedItem>(initialEquipped.current);
const [saved, setSaved] = useState<SavedItem>(Object.fromEntries(Object.keys(ITEM_KEYS).map(k => [k, true])) as any);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playingVoice, setPlayingVoice] = useState(false);
const [selectedLine, setSelectedLine] = useState(new Set(['0035']));
const [playPreviews, setPlayPreviews] = useState(true);
const [selectingVoice, setSelectingVoice] = useState<EquippedItem['systemVoice'] | null>(null);
const equipItem = <K extends keyof RequiredUserbox>(k: K, item: RequiredUserbox[K][number] | undefined | null) => {
if (!item || equipped[k] === item) return;
setEquipped(e => ({ ...e, [k]: item }));
setSaved(s => ({ ...s, [k]: false }));
};
const reset = <K extends keyof RequiredUserbox>(...items: K[]) => {
setSaved(s => ({ ...s, ...Object.fromEntries(items.map(i => [i, true])) }));
setEquipped(e => ({ ...e, ...Object.fromEntries(Object
.entries(initialEquipped.current).filter(([k]) => items.includes(k as any))) }))
};
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = 0.25;
const setPlay = () => {
setPlayingVoice(true);
};
const setStop = () => {
setPlayingVoice(false);
};
audio.addEventListener('play', setPlay);
audio.addEventListener('ended', setStop);
audio.addEventListener('pause', setStop)
return () => {
audio.removeEventListener('play', setPlay);
audio.removeEventListener('ended', setStop);
audio.removeEventListener('pause', setStop);
audio.pause();
};
}, []);
const play = (src: string) => {
if (!audioRef.current || !playPreviews) return;
audioRef.current.pause();
audioRef.current.src = src;
audioRef.current.currentTime = 0;
audioRef.current.play();
};
const stop = () => audioRef.current?.pause();
const voicePreview = (<div className="flex rounded-xl overflow-hidden flex-grow">
<Select label="Preview Voice Line" size="sm" radius="none" className="overflow-hidden min-w-56"
isDisabled={!playPreviews}
selectedKeys={selectedLine} onSelectionChange={s => {
if (typeof s === 'string' || !s.size) return;
setSelectedLine(s as any);
play(getAudioUrl(`chuni/system-voice/${selectingVoice?.cuePath ?? equipped.systemVoice.cuePath}_${[...s][0]}`))
}}>
{CHUNI_VOICE_LINES.map(([line, id]) => <SelectItem key={id}>{line}</SelectItem>)}
</Select>
<Button isIconOnly color="primary" className="p-1.5 h-full" radius="none" size="lg" isDisabled={!playPreviews}
onPress={() => playingVoice ? stop() :
play(getAudioUrl(`chuni/system-voice/${selectingVoice?.cuePath ?? equipped.systemVoice.cuePath}_${[...selectedLine][0]}`))}>
{playingVoice ? <StopIcon /> : <PlayIcon />}
</Button>
</div>);
const renderItem = (item: { name: string | undefined | null }, image: string, textClass='', containerClass='') => (
<div className={`w-full h-full flex flex-col border border-gray-500 rounded-2xl shadow-inner ${containerClass}`}>
<img alt={item.name ?? ''} className={`w-full ${textClass}`} src={image} />
<div className={textClass}>{ item.name }</div>
</div>
);
return (<div className="flex justify-center w-full">
<div className="grid grid-cols-12 justify-items-center max-w-[50rem] xl:max-w-[100rem] gap-2 flex-grow relative">
{/* begin nameplate and trophy */}
<div className="flex items-center justify-center w-full col-span-full xl:col-span-7">
<div className="flex flex-col items-center h-full w-full xl:max-w-none py-2 sm:p-4 sm:bg-content2 rounded-lg sm:shadow-inner">
<div className="text-2xl font-semibold mb-4 mr-auto px-2 flex w-full h-10">
Profile
{(!saved.namePlate || !saved.trophy) && <>
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('namePlate', 'trophy')}>
Reset
</Button>
<Button className="ml-2" color="primary">Save</Button>
</>}
</div>
<div className="w-full max-w-full">
<ChuniNameplate profile={profile ? {
...profile,
nameplateName: equipped.namePlate.name,
nameplateImage: equipped.namePlate.imagePath,
trophyName: equipped.trophy.name,
trophyRareType: equipped.trophy.rareType
} : null} className="w-full" />
</div>
<div className="flex gap-2 w-full px-2 sm:px-1">
<SelectModalButton className="flex-grow flex-1" displayMode="grid" modalSize="full" rowSize={230} colSize={500} gap={6} items={userboxItems.namePlate}
modalId="nameplate"
renderItem={n => renderItem(n, getImageUrl(`chuni/name-plate/${n.imagePath}`), 'w-full sm:text-lg', 'px-2 pb-1')}
selectedItem={equipped.namePlate} onSelected={i => equipItem('namePlate', i)}>
Change Nameplate
</SelectModalButton>
<SelectModalButton className="flex-grow flex-1" displayMode="list" modalSize="2xl" rowSize={66} items={userboxItems.trophy}
modalId="trophy"
renderItem={n => <ChuniTrophy rarity={n.rareType} name={n.name} />}
selectedItem={equipped.trophy} onSelected={i => equipItem('trophy', i)}>
Change Trophy
</SelectModalButton>
</div>
</div>
</div>
{/* end nameplate and trophy */}
<Divider className="sm:hidden mt-2 col-span-full" />
{/* begin avatar */}
<div className="col-span-full xl:col-span-5 flex flex-col w-full py-2 sm:pl-3 sm:pr-6 rounded-lg sm:shadow-inner sm:bg-content2">
<div className="text-2xl font-semibold px-2 mt-2 -mb-3 flex h-12">
Avatar
{AVATAR_KEYS.some(k => !saved[k]) && <>
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset(...AVATAR_KEYS)}>
Reset
</Button>
<Button className="ml-2" color="primary">Save</Button>
</>}
</div>
<div className="flex flex-col sm:flex-row h-full w-full items-center ">
<div className="w-full max-w-96">
<ChuniAvatar className="w-full sm:w-auto sm:h-96"
wear={equipped.avatarWear.texturePath}
head={equipped.avatarHead.texturePath}
face={equipped.avatarFace.texturePath}
skin={equipped.avatarSkin.texturePath}
item={equipped.avatarItem.texturePath}
back={equipped.avatarBack.texturePath}/>
</div>
<div className="grid grid-cols-2 w-full px-2 sm:px-0 sm:flex flex-col gap-1.5 sm:ml-3 flex-grow">
{(['avatarHead', 'avatarFace', 'avatarWear', 'avatarSkin', 'avatarItem', 'avatarBack'] as const).map(k => ((k !== 'avatarSkin' || userboxItems.avatarSkin.length > 1) && <SelectModalButton
key={k} displayMode="grid" modalSize="3xl" colSize={175} rowSize={205} gap={5} modalId={k}
className={(k === 'avatarBack' && userboxItems.avatarSkin.length === 1) ? 'w-full col-span-full' : 'w-full'}
onSelected={i => equipItem(k, i)} items={userboxItems[k]} selectedItem={equipped[k]}
renderItem={i => renderItem(i, getImageUrl(`chuni/avatar/${i.iconPath}`)) }>
Change {k.slice(6)}
</SelectModalButton>))}
</div>
</div>
</div>
{/* end avatar */}
<Divider className="sm:hidden mt-2 col-span-full" />
{/* begin system voice */}
<div className="flex flex-col p-4 w-full col-span-full xl:col-span-6 sm:bg-content2 rounded-lg sm:shadow-inner items-center">
<audio ref={audioRef} />
<div className="text-2xl font-semibold mb-4 px-2 flex w-full h-10">
Voice
{!saved.systemVoice && <>
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('systemVoice')}>
Reset
</Button>
<Button className="ml-2" color="primary">Save</Button>
</>}
</div>
<div className="flex w-full flex-col sm:flex-row items-center">
<div className="flex flex-col">
<img className="w-80 max-w-full"
alt={equipped.systemVoice.name ?? ''} src={getImageUrl(`chuni/system-voice-icon/${equipped.systemVoice.imagePath}`)} />
<span className="text-center">{ equipped.systemVoice.name }</span>
</div>
<div className="flex flex-col flex-grow w-full mt-3 sm:-mt-5 sm:w-auto gap-2">
<Checkbox isSelected={playPreviews} onValueChange={setPlayPreviews} size="lg" className="text-nowrap">
<span className="text-sm">Enable Previews</span>
</Checkbox>
{ voicePreview }
<SelectModalButton selectedItem={equipped.systemVoice} items={userboxItems.systemVoice}
displayMode="grid" rowSize={150} colSize={175} gap={6} modalSize="full"
modalId="system-voice"
footer={<><div className="flex flex-grow gap-2 items-center max-w-full sm:max-w-[min(100%,18rem)]">
{ voicePreview }
</div>
<Checkbox isSelected={playPreviews} onValueChange={setPlayPreviews} size="lg" className="text-nowrap mr-auto">
<span className="text-sm">Enable Previews</span>
</Checkbox>
</>}
onSelected={i => {
setSelectingVoice(i ?? null);
stop();
if (i) equipItem('systemVoice', i);
}}
onSelectionChanged={i => {
play(getAudioUrl(`chuni/system-voice/${i.cuePath}_${[...selectedLine][0]}`));
setSelectingVoice(i);
}}
renderItem={i => renderItem(i, getImageUrl(`chuni/system-voice-icon/${i.imagePath}`))}>
Change Voice
</SelectModalButton>
</div>
</div>
</div>
{/* end system voice */}
<Divider className="sm:hidden mt-2 col-span-full" />
{/* begin map icon*/}
<div className="flex flex-col p-4 w-full col-span-full xl:col-span-6 sm:bg-content2 rounded-lg sm:shadow-inner items-center">
<div className="text-2xl font-semibold mb-4 px-2 flex w-full h-10">
Map Icon
{!saved.mapIcon && <>
<Button className="ml-auto" color="danger" variant="light" onPress={() => reset('mapIcon')}>
Reset
</Button>
<Button className="ml-2" color="primary">Save</Button>
</>}
</div>
<img className="w-52 max-w-full -mt-4 sm:-mt-12"
alt={equipped.mapIcon.name ?? ''} src={getImageUrl(`chuni/map-icon/${equipped.mapIcon.imagePath}`)} />
<span className="text-center mb-2">{ equipped.mapIcon.name }</span>
<SelectModalButton onSelected={i => i && equipItem('mapIcon', i)} selectedItem={equipped.mapIcon}
displayMode="grid" modalSize="full" rowSize={210} colSize={175} items={userboxItems.mapIcon} gap={6}
className="w-full sm:w-auto" modalId="map-icon"
renderItem={i => renderItem(i, getImageUrl(`chuni/map-icon/${i.imagePath}`))}>
Change Map Icon
</SelectModalButton>
</div>
{/* end map icon */}
{Object.values(saved).some(x => !x) && <Button className="fixed bottom-3 right-3 hidden sm:flex" color="primary" radius="full" startContent={<SaveIcon className="h-6" />}>
Save All
</Button>}
{Object.values(saved).some(x => !x) && <>
<div className="block sm:hidden h-20" />
<div className="flex sm:hidden fixed z-40 items-center font-semibold bottom-0 left-0 w-full p-3 bg-content1 gap-2 flex-wrap ">
You have unsaved changes
<Button className="ml-auto" color="primary">
Save All
</Button>
</div>
</>}
</div>
</div>);
};

View File

@ -0,0 +1,3 @@
export const SaveIcon = ({ className }: { className?: string }) => <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" >
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v18h18V6l-3-3H3ZM7.5 3v6h9V3M6 21v-9h12v9M14.25 5.25v1.5"/>
</svg>;

View File

@ -0,0 +1,9 @@
export const AvatarCategory = {
WEAR: 1,
HEAD: 2,
FACE: 3,
SKIN: 4,
ITEM: 5,
FRONT: 6,
BACK: 7
} as { [key: string]: number };

View File

@ -0,0 +1,8 @@
export const enum ItemKind {
NAME_PLATE = 1,
FRAME = 2,
TROPHY = 3,
MAP_ICON = 8,
SYSTEM_VOICE = 9,
AVATAR_ACCESSORY = 11
}

View File

@ -0,0 +1,23 @@
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
export const CHUNI_VOICE_LINES: readonly [string, string][] = [
['𝅘𝅥𝅮 SEGA 𝅘𝅥𝅮', '0035'],
['CHUNITHM', '0036'],
['Welcome to CHUNITHM', '0041'],
['Full Combo', '0001'],
['All Justice', '0002'],
['Full Chain', '0008'],
['New Record', '0009'],
['All Clear', '0010'],
['Genkai Toppa', '0046'],
['Quest Clear', '0047'],
['Continue?', '0050'],
['Continue!', '0051'],
['See You Next Play', '0052'],
...CHUNI_SCORE_RANKS
.map((rank, i) => [
`Rank ${rank}`,
(i + 100).toString().padStart(4, '0')
] as [string, string])
.reverse()
];

View File

@ -0,0 +1,11 @@
import { useEffect } from 'react';
export const useWindowListener = <K extends keyof WindowEventMap>(event: K,
listener: (this: Window, ev: WindowEventMap[K]) => any,
deps: any[] = [],
options?: boolean | AddEventListenerOptions) => {
useEffect(() => {
window.addEventListener(event, listener, options);
return () => window.removeEventListener(event, listener, options);
}, [event, listener, options, ...deps]);
};

View File

@ -0,0 +1,33 @@
import { AliasedExpression, AliasNode, ColumnNode, Expression, IdentifierNode, ReferenceNode, sql } from 'kysely';
export const jsonObjectArray = (...refs: (Expression<any> | AliasedExpression<any, string>)[]) => {
const args: string[] = [];
for (const ref of refs) {
let node = ref.toOperationNode();
let name: string | null = null;
if (AliasNode.is(node)) {
if (!IdentifierNode.is(node.alias))
throw TypeError(`unexpected alias type ${node.alias}`)
name = node.alias.name;
node = node.node;
}
if (!ReferenceNode.is(node))
throw TypeError(`unexpected node type ${node.kind}`);
if (!ColumnNode.is(node.column))
throw TypeError('cannot use select all with json');
name ??= node.column.column.name;
args.push(`'${name}'`);
let identifier: string = '`' + node.column.column.name + '`';
if (node.table)
identifier = '`' + node.table.table.identifier.name + '`.' +identifier;
args.push(identifier);
}
return sql<string>`JSON_ARRAYAGG(JSON_OBJECT(${sql.raw(args.join(','))}))`;
};

View File

@ -1,3 +1,3 @@
export type PickNullable<T, K extends keyof NonNullable<T>> = export type PickNullable<T, K extends keyof NonNullable<T>> =
T extends undefined | null ? T : T extends undefined | null ? T :
Pick<NonNullable<T>, K>; Pick<NonNullable<T>, K> | null | undefined;