zzzUI

Loadout

React 装备配置界面:一侧是货舱,另一侧是选中物品的详情,包括稀有度、耐久与重量;物品可装备到腰带栏,也可按住拆解。

安装

terminal
# once per project: where @zzzui items come frompnpm dlx shadcn@latest registry add @zzzui=https://zzzui.catforest.dev/r/{name}.jsonpnpm dlx shadcn@latest add @zzzui/loadout

预览

Loadout

8,640cr

Belt
legendary

Gate key

Opens any gate on the north wall, once.

Durability
x
Weight
x

Worth 2,400 cr each · 1 in the hold

loadout.tsx
import { type ComponentType, useState } from "react";import { Badge, type Rarity } from "@/components/zero/badge";import { Button } from "@/components/zero/button";import { Counter } from "@/components/zero/counter";import { Gauge, GaugeLabel, GaugeValue } from "@/components/zero/gauge";import { HoldButton } from "@/components/zero/hold-button";import { CellIcon } from "@/components/zero/icons/cell";import { ChipIcon } from "@/components/zero/icons/chip";import { CrateIcon } from "@/components/zero/icons/crate";import { FlareIcon } from "@/components/zero/icons/flare";import { KeyIcon } from "@/components/zero/icons/key";import { KitIcon } from "@/components/zero/icons/kit";import { Inventory, InventorySlot } from "@/components/zero/inventory";/* * A loadout screen: the hold on one side, what is chosen on the other — its tier, what it is, * how worn and how heavy, and what to do with it. Equipping puts it in one of three slots on the * belt; scrapping it is held, and pays its worth into the credits, which count up. The items are * local state; feed your own. */type Thing = {  id: string;  name: string;  icon: ComponentType;  rarity: Rarity;  count: number;  line: string;  durability: number;  weight: number;  worth: number;};const hold: Thing[] = [  {    id: "key",    name: "Gate key",    icon: KeyIcon,    rarity: "legendary",    count: 1,    line: "Opens any gate on the north wall, once.",    durability: 100,    weight: 1,    worth: 2400,  },  {    id: "crate",    name: "Sealed crate",    icon: CrateIcon,    rarity: "rare",    count: 2,    line: "Contents unknown. The seal must reach the gate unbroken.",    durability: 72,    weight: 4,    worth: 900,  },  {    id: "chip",    name: "Route chip",    icon: ChipIcon,    rarity: "epic",    count: 1,    line: "Shows the patrols' routes for one night.",    durability: 55,    weight: 1,    worth: 1300,  },  {    id: "cell",    name: "Power cell",    icon: CellIcon,    rarity: "uncommon",    count: 6,    line: "Refills the shield by a third.",    durability: 90,    weight: 2,    worth: 120,  },  {    id: "kit",    name: "Repair kit",    icon: KitIcon,    rarity: "uncommon",    count: 3,    line: "Patches the hull by thirty.",    durability: 100,    weight: 2,    worth: 160,  },  {    id: "flare",    name: "Signal flare",    icon: FlareIcon,    rarity: "common",    count: 12,    line: "Marks a spot on everyone's map for a minute.",    durability: 100,    weight: 1,    worth: 15,  },];const SLOTS = 20;const BELT = 3;export function Loadout() {  const [things, setThings] = useState(hold);  const [chosen, setChosen] = useState(hold[0].id);  const [belt, setBelt] = useState<string[]>(["kit"]);  const [credits, setCredits] = useState(8640);  const thing = things.find((t) => t.id === chosen);  const scrap = (id: string) => {    const gone = things.find((t) => t.id === id);    if (!gone) return;    setCredits((c) => c + gone.worth);    if (gone.count > 1) {      setThings((list) =>        list.map((t) => (t.id === id ? { ...t, count: t.count - 1 } : t)),      );      return;    }    const rest = things.filter((t) => t.id !== id);    setThings(rest);    setBelt((list) => list.filter((b) => b !== id));    setChosen(rest[0]?.id ?? "");  };  const worn = belt.includes(chosen);  return (    <div className="@container w-full max-w-5xl">      <section        aria-labelledby="loadout-title"        className="grid border-(length:--zero-stroke) border-zero-ink bg-zero-surface bg-(image:--zero-hatch)"      >        <header className="flex flex-wrap items-end justify-between gap-4 bg-zero-void px-6 pt-6 pb-5">          <h2            id="loadout-title"            className="font-black font-zero text-zero-2xl italic"          >            Loadout          </h2>          <p className="flex items-baseline gap-1.5">            <Counter value={credits} delta className="text-zero-xl" />            <span className="font-black font-zero text-zero-accent italic">              cr            </span>          </p>        </header>        <div className="grid gap-8 p-6 @3xl:grid-cols-[auto_1fr]">          <div className="grid content-start gap-5">            <Inventory columns={5} aria-label="Cargo hold">              {Array.from({ length: SLOTS }, (_, index) => {                const t = things[index];                if (!t)                  // biome-ignore lint/suspicious/noArrayIndexKey: empty slots are only their place                  return <InventorySlot key={index} aria-label="Empty" />;                return (                  <InventorySlot                    key={t.id}                    rarity={t.rarity}                    count={t.count}                    selected={t.id === chosen}                    aria-label={`${t.name}, ${t.count}`}                    onClick={() => setChosen(t.id)}                  >                    <t.icon />                  </InventorySlot>                );              })}            </Inventory>            <div className="grid gap-2">              <span className="font-black font-zero text-zero-sm uppercase italic tracking-[0.06em]">                Belt              </span>              <Inventory columns={BELT} aria-label="Belt">                {Array.from({ length: BELT }, (_, index) => {                  const t = things.find((x) => x.id === belt[index]);                  return t ? (                    <InventorySlot                      key={t.id}                      rarity={t.rarity}                      aria-label={`${t.name}, on the belt`}                      onClick={() => setChosen(t.id)}                    >                      <t.icon />                    </InventorySlot>                  ) : (                    // biome-ignore lint/suspicious/noArrayIndexKey: empty slots are only their place                    <InventorySlot key={index} aria-label="Empty belt slot" />                  );                })}              </Inventory>            </div>          </div>          {thing ? (            <div className="grid content-start gap-5">              <div className="grid gap-2">                <Badge rarity={thing.rarity} className="w-fit">                  {thing.rarity}                </Badge>                <h3 className="font-black font-zero text-zero-xl italic">                  {thing.name}                </h3>                <p className="font-medium font-zero-text text-zero-md text-zero-text-2">                  {thing.line}                </p>              </div>              <Gauge value={thing.durability} tone="uncommon" low={0.3}>                <GaugeLabel>Durability</GaugeLabel>                <GaugeValue />              </Gauge>              <Gauge value={thing.weight} max={5} segments={5} tone="legendary">                <GaugeLabel>Weight</GaugeLabel>                <GaugeValue />              </Gauge>              <p className="font-medium font-zero-text text-zero-sm text-zero-text-3">                Worth {thing.worth.toLocaleString("en-US")} cr each ·{" "}                {thing.count} in the hold              </p>              <div className="flex flex-wrap gap-3 border-zero-line border-t-(length:--zero-stroke-thin) pt-5">                <Button                  disabled={!worn && belt.length >= BELT}                  onClick={() =>                    setBelt((list) =>                      worn                        ? list.filter((b) => b !== chosen)                        : [...list, chosen],                    )                  }                >                  {worn ? "Take off the belt" : "Put on the belt"}                </Button>                <HoldButton onConfirm={() => scrap(thing.id)}>                  Hold to scrap                </HoldButton>              </div>            </div>          ) : (            <p className="font-medium font-zero-text text-zero-text-3">              The hold is empty.            </p>          )}        </div>      </section>    </div>  );}

行为

选中一个物品格即可查看物品:稀有度以徽章显示,其下是物品说明,耐久与重量以数值条显示,最后是物品价值。Put on the belt 将物品移入三个腰带格之一;Hold to scrap 拆解一件物品,并按其价值计入信用点,数字随之递增。

两组网格内均可用方向键切换选中的物品格。代码块宽度足够时排成两栏,在手机上为一栏。物品为本地状态,可替换为自己的数据。

由 Inventory、Gauge、Counter、Badge、Button、Hold button 组成,安装此代码块时会一并安装这些组件。