118 lines
16 KiB
TypeScript
118 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { CopyIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { CatalogCombobox } from "@/components/catalog-combobox";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { calculateSubstats, generateTeam, SUBSTAT_SCALARS } from "@/lib/translator/generate";
|
||
import { formatBaseStat } from "@/lib/translator/stat-format";
|
||
import { calculateFinalStats, formatFinalStat } from "@/lib/translator/final-stats";
|
||
import { rollKeys, translationSchema, type Build, type Catalog, type CatalogItem, type MainStatKey, type RollKey, type Team, type Translation } from "@/lib/translator/types";
|
||
|
||
const emptyRolls = Object.fromEntries(rollKeys.map((key) => [key, 0])) as Record<RollKey, number>;
|
||
const levels = [20, 40, 50, 60, 70, 80, 90];
|
||
const sands: MainStatKey[] = ["atk%", "hp%", "def%", "em", "er"];
|
||
const goblets: MainStatKey[] = ["atk%", "hp%", "def%", "em", "pyro%", "hydro%", "cryo%", "electro%", "anemo%", "geo%", "dendro%", "phys%"];
|
||
const circlets: MainStatKey[] = ["cr", "cd", "atk%", "hp%", "def%", "em", "heal"];
|
||
|
||
function newId(prefix: string): string {
|
||
const uuid = globalThis.crypto?.randomUUID?.();
|
||
return uuid ? `${prefix}-${uuid}` : `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
|
||
function makeBuild(catalog: Catalog, character = catalog.characters[0], id = newId("build")): Build {
|
||
const weapon = catalog.weapons.find((item) => !character.weaponType || item.weaponType === character.weaponType) ?? catalog.weapons[0];
|
||
const artifact = catalog.artifacts[0];
|
||
return { id, characterId: character.id, characterKey: character.key, characterName: character.name, characterImageUrl: character.imageUrl, level: 90, maxLevel: 90, constellation: 0, talents: [9, 9, 9], weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, weaponLevel: 90, weaponMaxLevel: 90, refinement: 1, artifactMode: "4pc", artifactOneId: artifact.id, artifactOneKey: artifact.key, artifactOneName: artifact.name, artifactTwoId: "", artifactTwoKey: "", artifactTwoName: "", sands: "atk%", goblet: "atk%", circlet: "cr", rolls: { ...emptyRolls } };
|
||
}
|
||
function makeTeam(catalog: Catalog, index: number, deterministic = false): Team {
|
||
const id = deterministic ? `team-${index}` : newId("team");
|
||
return { id, name: `Team ${index}`, builds: [makeBuild(catalog, catalog.characters[0], deterministic ? `build-${index}-1` : newId("build"))] };
|
||
}
|
||
|
||
async function copyText(value: string): Promise<void> {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(value);
|
||
return;
|
||
}
|
||
const textarea = document.createElement("textarea");
|
||
textarea.value = value;
|
||
textarea.setAttribute("readonly", "");
|
||
textarea.style.position = "fixed";
|
||
textarea.style.opacity = "0";
|
||
document.body.append(textarea);
|
||
textarea.select();
|
||
const copied = document.execCommand("copy");
|
||
textarea.remove();
|
||
if (!copied) throw new Error("Clipboard access is unavailable.");
|
||
}
|
||
|
||
function NumberSelect({ value, values, onChange }: { value: number; values: number[]; onChange: (value: number) => void }) {
|
||
return <Select value={String(value)} onValueChange={(next) => onChange(Number(next))}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{values.map((item) => <SelectItem key={item} value={String(item)}>{item}</SelectItem>)}</SelectGroup></SelectContent></Select>;
|
||
}
|
||
function StatSelect({ value, values, onChange }: { value: MainStatKey; values: MainStatKey[]; onChange: (value: MainStatKey) => void }) {
|
||
return <Select value={value} onValueChange={(next) => onChange(next as MainStatKey)}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{values.map((item) => <SelectItem key={item} value={item}>{item.toUpperCase()}</SelectItem>)}</SelectGroup></SelectContent></Select>;
|
||
}
|
||
|
||
function BaseStats({ stats }: { stats: CatalogItem["baseStats"] }) {
|
||
if (!stats.length) return <FieldDescription>Level 90 base stats unavailable.</FieldDescription>;
|
||
return <div className="flex flex-wrap gap-2" aria-label="Level 90 base stats">{stats.map((stat) => <Badge key={stat.key} variant="outline">{formatBaseStat(stat)}</Badge>)}</div>;
|
||
}
|
||
|
||
function BuildEditor({ build, catalog, onChange, onRemove, canRemove }: { build: Build; catalog: Catalog; onChange: (build: Build) => void; onRemove: () => void; canRemove: boolean }) {
|
||
const selectedCharacter = catalog.characters.find((item) => item.id === build.characterId);
|
||
const selectedWeapon = catalog.weapons.find((item) => item.id === build.weaponId);
|
||
const weapons = catalog.weapons.filter((item) => !selectedCharacter?.weaponType || item.weaponType === selectedCharacter.weaponType);
|
||
const update = <K extends keyof Build>(key: K, value: Build[K]) => onChange({ ...build, [key]: value });
|
||
const chooseCharacter = (item: CatalogItem) => {
|
||
const weapon = catalog.weapons.find((candidate) => !item.weaponType || candidate.weaponType === item.weaponType) ?? catalog.weapons[0];
|
||
onChange({ ...build, characterId: item.id, characterKey: item.key, characterName: item.name, characterImageUrl: item.imageUrl, weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name });
|
||
};
|
||
const calculated = calculateSubstats(build);
|
||
const finalStats = selectedCharacter?.baseStats.length && selectedWeapon?.baseStats.length
|
||
? calculateFinalStats(build, selectedCharacter.baseStats, selectedWeapon.baseStats)
|
||
: [];
|
||
const totalRolls = Object.values(build.rolls).reduce((sum, value) => sum + value, 0);
|
||
return <Card><CardHeader><div><CardTitle>{build.characterName}</CardTitle><CardDescription>{build.characterKey} · {totalRolls} total rolls</CardDescription></div><CardAction className="flex items-start gap-2"><div className="flex flex-col items-end gap-1" aria-label="Final level 90 stats">{finalStats.length ? finalStats.map((stat) => <Badge key={stat.key} variant="secondary">{formatFinalStat(stat)}</Badge>) : <span className="text-sm text-muted-foreground">Final stats unavailable</span>}</div><Button variant="ghost" size="icon" disabled={!canRemove} onClick={onRemove} aria-label="Remove character"><Trash2Icon /></Button></CardAction></CardHeader><CardContent><FieldGroup>
|
||
<Field><FieldLabel>Character</FieldLabel><CatalogCombobox items={catalog.characters} value={build.characterId} onChange={chooseCharacter} placeholder="Choose character" /><BaseStats stats={selectedCharacter?.baseStats ?? []} /></Field>
|
||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.level} values={levels.filter((v) => v <= build.maxLevel)} onChange={(v) => update("level", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.maxLevel} values={levels} onChange={(v) => onChange({ ...build, maxLevel: v, level: Math.min(build.level, v) })} /></Field><Field><FieldLabel>Constellation</FieldLabel><NumberSelect value={build.constellation} values={[0,1,2,3,4,5,6]} onChange={(v) => update("constellation", v)} /></Field><Field><FieldLabel>Talents NA / Skill / Burst</FieldLabel><div className="grid grid-cols-3 gap-2">{build.talents.map((value, index) => <Input key={index} type="number" min={1} max={15} value={value} onChange={(event) => { const next = [...build.talents] as Build["talents"]; next[index] = Number(event.target.value); update("talents", next); }} aria-label={["Normal attack", "Skill", "Burst"][index]} />)}</div></Field></div>
|
||
<Separator />
|
||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr_1fr_1fr]"><Field><FieldLabel>Weapon</FieldLabel><CatalogCombobox items={weapons} value={build.weaponId} onChange={(item) => onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name })} placeholder="Choose weapon" /><BaseStats stats={selectedWeapon?.baseStats ?? []} /></Field><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.weaponLevel} values={levels.filter((v) => v <= build.weaponMaxLevel)} onChange={(v) => update("weaponLevel", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.weaponMaxLevel} values={levels} onChange={(v) => onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} /></Field><Field><FieldLabel>Refinement</FieldLabel><NumberSelect value={build.refinement} values={[1,2,3,4,5]} onChange={(v) => update("refinement", v)} /></Field></div>
|
||
<Separator />
|
||
<FieldSet><FieldLegend>Artifact sets and main stats</FieldLegend><div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"><Field><FieldLabel>Set layout</FieldLabel><Select value={build.artifactMode} onValueChange={(v) => update("artifactMode", v as Build["artifactMode"])}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="4pc">4-piece set</SelectItem><SelectItem value="2+2">2-piece + 2-piece</SelectItem></SelectGroup></SelectContent></Select></Field><Field><FieldLabel>First set</FieldLabel><CatalogCombobox items={catalog.artifacts} value={build.artifactOneId} onChange={(item) => onChange({ ...build, artifactOneId: item.id, artifactOneKey: item.key, artifactOneName: item.name })} placeholder="Choose artifact" /></Field>{build.artifactMode === "2+2" ? <Field><FieldLabel>Second set</FieldLabel><CatalogCombobox items={catalog.artifacts.filter((item) => item.id !== build.artifactOneId)} value={build.artifactTwoId} onChange={(item) => onChange({ ...build, artifactTwoId: item.id, artifactTwoKey: item.key, artifactTwoName: item.name })} placeholder="Choose artifact" /></Field> : null}<Field><FieldLabel>Sands</FieldLabel><StatSelect value={build.sands} values={sands} onChange={(v) => update("sands", v as Build["sands"])} /></Field><Field><FieldLabel>Goblet</FieldLabel><StatSelect value={build.goblet} values={goblets} onChange={(v) => update("goblet", v as Build["goblet"])} /></Field><Field><FieldLabel>Circlet</FieldLabel><StatSelect value={build.circlet} values={circlets} onChange={(v) => update("circlet", v as Build["circlet"])} /></Field></div></FieldSet>
|
||
<Separator />
|
||
<FieldSet><FieldLegend>Average substat rolls</FieldLegend><FieldDescription>Enter roll counts. Values use gcsim's KQM average-roll scalars.</FieldDescription><div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-5">{rollKeys.map((key) => <Field key={key}><FieldLabel htmlFor={`${build.id}-${key}`}>{key.toUpperCase()} <span className="text-muted-foreground">× {key.includes("%") || ["er","cr","cd"].includes(key) ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%` : SUBSTAT_SCALARS[key]}</span></FieldLabel><Input id={`${build.id}-${key}`} type="number" min={0} max={99} value={build.rolls[key]} onChange={(event) => update("rolls", { ...build.rolls, [key]: Math.max(0, Math.min(99, Number(event.target.value))) })} /><FieldDescription>{key.includes("%") || ["er","cr","cd"].includes(key) ? `${(calculated[key] * 100).toFixed(2)}%` : calculated[key].toFixed(2)}</FieldDescription></Field>)}</div></FieldSet>
|
||
</FieldGroup></CardContent></Card>;
|
||
}
|
||
|
||
export function Translator({ catalog }: { catalog: Catalog }) {
|
||
const [translation, setTranslation] = useState<Translation>(() => ({ name: "My builds", teams: [makeTeam(catalog, 1, true)] }));
|
||
const [activeTeam, setActiveTeam] = useState(translation.teams[0].id);
|
||
useEffect(() => { localStorage.setItem("gcsim-translator-draft", JSON.stringify(translation)); }, [translation]);
|
||
useEffect(() => { const stored = localStorage.getItem("gcsim-translator-draft"); if (stored) try { const parsed = translationSchema.safeParse(JSON.parse(stored)); if (parsed.success) setTimeout(() => { setTranslation(parsed.data); setActiveTeam(parsed.data.teams[0].id); }, 0); } catch { localStorage.removeItem("gcsim-translator-draft"); } }, []);
|
||
const team = translation.teams.find((item) => item.id === activeTeam) ?? translation.teams[0];
|
||
const output = useMemo(() => generateTeam(team), [team]);
|
||
const updateTeam = (next: Team) => setTranslation((current) => ({ ...current, teams: current.teams.map((item) => item.id === next.id ? next : item) }));
|
||
const copy = async (value = output) => {
|
||
try {
|
||
await copyText(value);
|
||
toast.success("GCSim build copied");
|
||
} catch {
|
||
toast.error("Could not copy automatically. Select the generated config and copy it manually.");
|
||
}
|
||
};
|
||
return <main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-8 sm:px-6 lg:py-12"><Header /><div className="flex flex-col gap-2"><Field className="max-w-md"><FieldLabel htmlFor="translation-name">Collection name</FieldLabel><Input id="translation-name" value={translation.name} onChange={(event) => setTranslation({ ...translation, name: event.target.value })} /><FieldDescription>Saved automatically in this browser. Nothing is written to the database.</FieldDescription></Field></div><Tabs value={activeTeam} onValueChange={setActiveTeam}><div className="flex items-center gap-2 overflow-x-auto"><TabsList>{translation.teams.map((item) => <TabsTrigger key={item.id} value={item.id}>{item.name}</TabsTrigger>)}</TabsList><Button variant="outline" size="icon" disabled={translation.teams.length >= 12} onClick={() => { const next = makeTeam(catalog, translation.teams.length + 1); setTranslation({ ...translation, teams: [...translation.teams, next] }); setActiveTeam(next.id); }} aria-label="Add team"><PlusIcon /></Button></div>{translation.teams.map((item) => <TabsContent key={item.id} value={item.id}><div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_420px]"><div className="flex flex-col gap-6"><div className="flex items-end gap-3"><Field><FieldLabel>Team name</FieldLabel><Input value={item.name} onChange={(event) => updateTeam({ ...item, name: event.target.value })} /></Field><Button variant="ghost" size="icon" disabled={translation.teams.length === 1} onClick={() => { const teams = translation.teams.filter((candidate) => candidate.id !== item.id); setTranslation({ ...translation, teams }); setActiveTeam(teams[0].id); }} aria-label="Remove team"><Trash2Icon /></Button></div>{item.builds.map((build) => <BuildEditor key={build.id} build={build} catalog={catalog} canRemove={item.builds.length > 1} onRemove={() => updateTeam({ ...item, builds: item.builds.filter((candidate) => candidate.id !== build.id) })} onChange={(next) => updateTeam({ ...item, builds: item.builds.map((candidate) => candidate.id === next.id ? next : candidate) })} />)}<Button variant="outline" disabled={item.builds.length >= 4} onClick={() => { const used = new Set(item.builds.map((build) => build.characterId)); const character = catalog.characters.find((candidate) => !used.has(candidate.id)); if (!character) return toast.error("No more characters available"); updateTeam({ ...item, builds: [...item.builds, makeBuild(catalog, character)] }); }}><PlusIcon data-icon="inline-start" />Add character ({item.builds.length}/4)</Button></div><div className="xl:sticky xl:top-8 xl:self-start"><OutputCard output={output} onCopy={() => copy()} /></div></div></TabsContent>)}</Tabs></main>;
|
||
}
|
||
|
||
function Header() { return <header className="flex flex-col gap-3"><div className="flex items-center gap-3"><Badge variant="secondary">GCSIM</Badge><span className="text-sm text-muted-foreground">Build translator</span></div><h1 className="max-w-3xl text-4xl font-semibold tracking-tight sm:text-5xl">Build teams, not config syntax.</h1><p className="max-w-2xl text-muted-foreground">Choose loadouts and average artifact rolls. Get clean character blocks ready to paste.</p></header>; }
|
||
function OutputCard({ output, onCopy }: { output: string; onCopy: () => void }) { return <Card><CardHeader><CardTitle>Generated config</CardTitle><CardDescription>Character build block for the active team.</CardDescription></CardHeader><CardContent><Textarea className="min-h-96 font-mono text-xs" value={output} readOnly aria-label="Generated gcsim config" /></CardContent><CardFooter><Button className="w-full" onClick={onCopy}><CopyIcon data-icon="inline-start" />Copy config</Button></CardFooter></Card>; }
|