@terrafirmagreg-team/game 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +58 -3
  2. package/src/components/entry/atlas-sprite.tsx +57 -0
  3. package/src/components/entry/copy-id-menu.tsx +55 -0
  4. package/src/components/entry/entry.tsx +229 -0
  5. package/src/components/entry/frame.tsx +32 -0
  6. package/src/components/entry/group.tsx +180 -0
  7. package/src/components/entry/icon/tabs.tsx +118 -0
  8. package/src/components/entry/icon.tsx +187 -0
  9. package/src/components/entry/list.tsx +70 -0
  10. package/src/components/entry/mc-text.tsx +92 -0
  11. package/src/components/entry/stack-count.tsx +14 -0
  12. package/src/components/entry/tooltip.tsx +129 -0
  13. package/src/components/mdx/item-icon.tsx +69 -0
  14. package/src/components/preview-icon.tsx +25 -0
  15. package/src/hooks/use-dump-location.ts +18 -0
  16. package/src/hooks/use-entry-context.ts +35 -0
  17. package/src/hooks/use-icon-source.ts +297 -0
  18. package/src/hooks/use-lang-map.ts +18 -0
  19. package/src/lib/dump/assets/atlas/dto.ts +49 -0
  20. package/src/lib/dump/assets/atlas/lookup.ts +206 -0
  21. package/src/lib/dump/assets/atlas/sprites.ts +130 -0
  22. package/src/lib/dump/assets/lang/load.ts +17 -0
  23. package/src/lib/dump/assets/lang/names.ts +244 -0
  24. package/src/lib/dump/assets/urls.ts +143 -0
  25. package/src/lib/dump/client/atlases.ts +83 -0
  26. package/src/lib/dump/client/dump.ts +188 -0
  27. package/src/lib/dump/client/groups.ts +31 -0
  28. package/src/lib/dump/client/keybinds.ts +11 -0
  29. package/src/lib/dump/client/mods.ts +16 -0
  30. package/src/lib/dump/client/registry.ts +125 -0
  31. package/src/lib/dump/client/tabs.ts +27 -0
  32. package/src/lib/dump/client/tags.ts +64 -0
  33. package/src/lib/dump/contract/kind.ts +61 -0
  34. package/src/lib/dump/contract/read.ts +77 -0
  35. package/src/lib/dump/contract/ref.ts +262 -0
  36. package/src/lib/dump/contract/text.ts +86 -0
  37. package/src/lib/dump/data/registry/dto.ts +56 -0
  38. package/src/lib/dump/data/registry/parse.ts +291 -0
  39. package/src/lib/dump/data/tab/dto.ts +13 -0
  40. package/src/lib/dump/data/tab/parse.ts +26 -0
  41. package/src/lib/dump/data/tag/dto.ts +7 -0
  42. package/src/lib/dump/data/tag/parse.ts +57 -0
  43. package/src/lib/dump/gen/data/guides/book.ts +56 -0
  44. package/src/lib/dump/gen/data/guides/category.ts +30 -0
  45. package/src/lib/dump/gen/data/guides/entry.ts +48 -0
  46. package/src/lib/dump/gen/data/models/model.ts +3 -0
  47. package/src/lib/dump/gen/data/quests/chapter.ts +126 -0
  48. package/src/lib/dump/gen/data/quests/chapter_groups.ts +11 -0
  49. package/src/lib/dump/gen/data/quests/data.ts +40 -0
  50. package/src/lib/dump/gen/data/recipes/recipe.ts +78 -0
  51. package/src/lib/dump/gen/index.ts +20 -0
  52. package/src/lib/dump/gen/keybinds.ts +3 -0
  53. package/src/lib/dump/gen/lang.ts +3 -0
  54. package/src/lib/dump/gen/manifest.ts +59 -0
  55. package/src/lib/dump/gen/registry/blocks.ts +60 -0
  56. package/src/lib/dump/gen/registry/entities.ts +49 -0
  57. package/src/lib/dump/gen/registry/fluids.ts +48 -0
  58. package/src/lib/dump/gen/registry/groups.ts +14 -0
  59. package/src/lib/dump/gen/registry/items.ts +51 -0
  60. package/src/lib/dump/gen/registry/recipe_categories.ts +64 -0
  61. package/src/lib/dump/gen/registry/recipes.ts +3 -0
  62. package/src/lib/dump/gen/registry/tabs.ts +46 -0
  63. package/src/lib/dump/gen/registry/tags.ts +11 -0
  64. package/src/lib/dump/json-reader.ts +231 -0
  65. package/src/lib/dump/manifest/dto.ts +11 -0
  66. package/src/lib/dump/manifest/parse.ts +167 -0
  67. package/src/lib/dump/mods/dto.ts +40 -0
  68. package/src/lib/dump/mods/index.ts +81 -0
  69. package/src/lib/dump/mods/load.ts +61 -0
  70. package/src/lib/dump/paths.ts +171 -0
  71. package/src/lib/dump/reader.ts +15 -0
  72. package/src/lib/entry/display.ts +4 -0
  73. package/src/lib/entry/entry.ts +57 -0
  74. package/src/lib/entry/formula.ts +226 -0
  75. package/src/lib/entry/href.ts +148 -0
  76. package/src/lib/entry/icon-load.ts +195 -0
  77. package/src/lib/entry/icon.ts +10 -0
  78. package/src/lib/entry/tooltip.ts +60 -0
  79. package/src/lib/preview.ts +18 -0
  80. package/src/lib/search.ts +31 -0
  81. package/src/lib/snbt.ts +246 -0
  82. package/src/locales/en_us.json +9 -0
  83. package/src/locales/ru_ru.json +9 -0
  84. package/src/locales.ts +5 -0
  85. package/src/types/entry.ts +52 -0
  86. package/styles/fonts.css +15 -0
  87. package/styles/tokens.css +15 -0
  88. package/styles.css +2 -0
  89. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/game",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ "./components/*": [
7
+ "./src/components/*.tsx",
8
+ "./src/components/*.ts",
9
+ "./src/components/*/index.tsx",
10
+ "./src/components/*/index.ts"
11
+ ],
12
+ "./hooks/*": [
13
+ "./src/hooks/*.ts",
14
+ "./src/hooks/*.tsx",
15
+ "./src/hooks/*/index.ts",
16
+ "./src/hooks/*/index.tsx"
17
+ ],
18
+ "./lib/*": [
19
+ "./src/lib/*.ts",
20
+ "./src/lib/*.tsx",
21
+ "./src/lib/*/index.ts",
22
+ "./src/lib/*/index.tsx"
23
+ ],
24
+ "./types/*": [
25
+ "./src/types/*.ts",
26
+ "./src/types/*/index.ts"
27
+ ],
28
+ "./styles.css": "./styles.css",
29
+ "./locales": "./src/locales.ts"
30
+ },
31
+ "dependencies": {
32
+ "lucide-react": "^1.63.0",
33
+ "server-only": "^0.0.1",
34
+ "@terrafirmagreg-team/translate": "0.1.0",
35
+ "@terrafirmagreg-team/ui": "0.1.0",
36
+ "@terrafirmagreg-team/kit": "0.1.0",
37
+ "fumadocs-core": "16.17.8"
38
+ },
39
+ "peerDependencies": {
40
+ "next": ">=15",
41
+ "react": ">=19"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^26.6.5",
45
+ "@types/react": "^19.3.0",
46
+ "@types/react-dom": "^19.3.0"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
51
+ "directory": "packages/game"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "files": [
57
+ "src",
58
+ "styles",
59
+ "styles.css"
60
+ ]
6
61
  }
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import { atlasPagePublicUrl } from '@terrafirmagreg-team/game/lib/dump/assets/atlas/lookup';
4
+ import type { AtlasLookup } from '@terrafirmagreg-team/game/lib/dump/assets/atlas/dto';
5
+ import { Skeleton } from '@terrafirmagreg-team/ui/components/ui/skeleton';
6
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
7
+
8
+ export function AtlasSprite({
9
+ dataset,
10
+ lookup,
11
+ size = 16,
12
+ className,
13
+ }: {
14
+ dataset: string;
15
+ lookup: AtlasLookup | null;
16
+ size?: number;
17
+ className?: string;
18
+ }) {
19
+ const url = lookup
20
+ ? atlasPagePublicUrl(dataset, lookup.atlas, lookup.page)
21
+ : undefined;
22
+ if (!lookup || !url) {
23
+ return <Skeleton className={cn('inline-block shrink-0', className)} style={{ width: size, height: size }} />;
24
+ }
25
+ const { sprite, page } = lookup;
26
+ const scale = size / Math.max(sprite.w, 1);
27
+ const pos = `-${sprite.x * scale}px -${sprite.y * scale}px`;
28
+ const bgSize = `${page.w * scale}px ${page.h * scale}px`;
29
+ const tint = sprite.tintColor && sprite.tint !== 'none' ? sprite.tintColor : undefined;
30
+ return (
31
+ <span
32
+ className={className ?? 'relative inline-block shrink-0'}
33
+ style={{
34
+ width: size,
35
+ height: size,
36
+ backgroundImage: `url(${url})`,
37
+ backgroundPosition: pos,
38
+ backgroundSize: bgSize,
39
+ imageRendering: 'pixelated',
40
+ ...(tint
41
+ ? {
42
+ backgroundColor: tint,
43
+ backgroundBlendMode: 'multiply',
44
+ WebkitMaskImage: `url(${url})`,
45
+ maskImage: `url(${url})`,
46
+ WebkitMaskPosition: pos,
47
+ maskPosition: pos,
48
+ WebkitMaskSize: bgSize,
49
+ maskSize: bgSize,
50
+ WebkitMaskRepeat: 'no-repeat',
51
+ maskRepeat: 'no-repeat',
52
+ }
53
+ : {}),
54
+ }}
55
+ />
56
+ );
57
+ }
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { Copy } from 'lucide-react';
4
+ import type { ReactNode } from 'react';
5
+ import {
6
+ ContextMenu,
7
+ ContextMenuContent,
8
+ ContextMenuItem,
9
+ ContextMenuTrigger,
10
+ } from '@terrafirmagreg-team/ui/components/ui/context-menu';
11
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
12
+
13
+ export function CopyIdMenu({
14
+ id,
15
+ nbt,
16
+ label,
17
+ extra,
18
+ children,
19
+ }: {
20
+ id: string;
21
+
22
+ nbt?: string;
23
+
24
+ label?: string;
25
+
26
+ extra?: ReactNode;
27
+ children: ReactNode;
28
+ }) {
29
+ const t = useTranslations();
30
+ const copy = (value: string) => () => {
31
+ void navigator.clipboard.writeText(value);
32
+ };
33
+ return (
34
+ <ContextMenu>
35
+ <ContextMenuTrigger className="contents">{children}</ContextMenuTrigger>
36
+ <ContextMenuContent>
37
+ <ContextMenuItem onClick={copy(id)}>
38
+ <Copy />
39
+ {t(label ?? 'ui.entry.copy')}
40
+ </ContextMenuItem>
41
+ {nbt ? (
42
+ <ContextMenuItem onClick={copy(`${id}${nbt}`)}>
43
+ {t('ui.entry.copy_stack')}
44
+ </ContextMenuItem>
45
+ ) : null}
46
+ {nbt ? (
47
+ <ContextMenuItem onClick={copy(nbt)}>
48
+ {t('ui.entry.copy_nbt')}
49
+ </ContextMenuItem>
50
+ ) : null}
51
+ {extra}
52
+ </ContextMenuContent>
53
+ </ContextMenu>
54
+ );
55
+ }
@@ -0,0 +1,229 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import type { ReactElement } from 'react';
5
+ import { EntryIcon } from '@terrafirmagreg-team/game/components/entry/icon';
6
+ import { EntryTooltip } from '@terrafirmagreg-team/game/components/entry/tooltip';
7
+ import { CopyIdMenu } from '@terrafirmagreg-team/game/components/entry/copy-id-menu';
8
+ import { useEntryContext } from '@terrafirmagreg-team/game/hooks/use-entry-context';
9
+ import { useLangMap } from '@terrafirmagreg-team/game/hooks/use-lang-map';
10
+ import {
11
+ Tooltip,
12
+ TooltipContent,
13
+ TooltipTrigger,
14
+ } from '@terrafirmagreg-team/ui/components/ui/tooltip';
15
+ import {
16
+ classifyRefId,
17
+ isIconKind,
18
+ isTagKind,
19
+ refId,
20
+ } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
21
+ import { formatNbt } from '@terrafirmagreg-team/game/lib/snbt';
22
+ import { stackIdFromRef } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
23
+ import { stackDisplay } from '@terrafirmagreg-team/game/lib/entry/display';
24
+ import { loadStackDisplay } from '@terrafirmagreg-team/game/lib/entry/tooltip';
25
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
26
+ import {
27
+ resolveEntryHref,
28
+ tooltipLoadKind,
29
+ usesStackTooltip,
30
+ } from '@terrafirmagreg-team/game/lib/entry/entry';
31
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
32
+ import type { KindEntryProps, EntryProps } from '@terrafirmagreg-team/game/types/entry';
33
+
34
+ export function Entry({
35
+ reference: referenceProp,
36
+ id,
37
+ kind,
38
+ tag,
39
+ copy,
40
+ dataset: datasetProp,
41
+ name: nameProp,
42
+ lang: langProp,
43
+ langMap: langMapProp,
44
+ size = 'md',
45
+ variant = 'plain',
46
+ badge,
47
+ badgeClassName,
48
+ labeled = false,
49
+ label,
50
+ className,
51
+ missing,
52
+ tooltip,
53
+ children,
54
+ ...hrefOpts
55
+ }: EntryProps) {
56
+ const ctx = useEntryContext();
57
+ const dataset = datasetProp ?? ctx?.dataset;
58
+ const lang = langProp ?? ctx?.lang;
59
+ const fetchedLangMap = useLangMap(
60
+ dataset,
61
+ lang,
62
+ langMapProp === undefined && !ctx?.langMap,
63
+ );
64
+ const langMap = langMapProp ?? ctx?.langMap ?? fetchedLangMap;
65
+ const classified = id ? classifyRefId(id, kind) : undefined;
66
+ const reference =
67
+ referenceProp ??
68
+ (classified && tag !== undefined ? { ...classified, tag } : classified);
69
+ const refKind = reference?.kind;
70
+ const refIdStr = reference?.id ?? id ?? '';
71
+ const iconKind = refKind && isIconKind(refKind) ? refKind : undefined;
72
+ const stackId = reference ? stackIdFromRef(reference) : refIdStr;
73
+ const displayKey =
74
+ dataset &&
75
+ stackId &&
76
+ (refKind === 'item' ||
77
+ refKind === 'block' ||
78
+ refKind === 'fluid' ||
79
+ refKind === 'entity')
80
+ ? `${dataset}:${stackId}`
81
+ : undefined;
82
+ const displayPeek =
83
+ displayKey && dataset
84
+ ? loadStackDisplay.peek(
85
+ dataset,
86
+ stackId,
87
+ refKind === 'fluid'
88
+ ? 'fluid'
89
+ : refKind === 'entity'
90
+ ? 'entity'
91
+ : 'item',
92
+ )
93
+ : undefined;
94
+ const stackDisplayRes = useAsyncResult(
95
+ displayKey && dataset && displayPeek === undefined
96
+ ? async () => ({
97
+ key: displayKey,
98
+ display: await loadStackDisplay(
99
+ dataset,
100
+ stackId,
101
+ refKind === 'fluid'
102
+ ? 'fluid'
103
+ : refKind === 'entity'
104
+ ? 'entity'
105
+ : 'item',
106
+ ),
107
+ })
108
+ : false,
109
+ [dataset, stackId, displayKey, refKind, displayPeek],
110
+ );
111
+ const stackMissing =
112
+ displayKey !== undefined &&
113
+ displayPeek === undefined &&
114
+ !stackDisplayRes.pending &&
115
+ stackDisplayRes.value?.key === displayKey &&
116
+ stackDisplayRes.value.display === undefined;
117
+ const langKey =
118
+ displayPeek !== undefined
119
+ ? displayPeek?.name
120
+ : stackDisplayRes.value && stackDisplayRes.value.key === displayKey
121
+ ? stackDisplayRes.value.display?.name
122
+ : undefined;
123
+ const display = stackDisplay(langMap, stackId, refKind, langKey);
124
+ const name = nameProp ?? display.name;
125
+ const preferVariant =
126
+ reference?.tag === undefined &&
127
+ (refKind === 'item' || refKind === 'block' || refKind === 'fluid') &&
128
+ langMap !== undefined &&
129
+ !display.localized;
130
+ const resolved =
131
+ dataset && !stackMissing
132
+ ? resolveEntryHref({
133
+ reference,
134
+ id: refIdStr,
135
+ kind: refKind,
136
+ tag,
137
+ dataset,
138
+ name,
139
+ lang,
140
+ ...hrefOpts,
141
+ })
142
+ : undefined;
143
+ const faceClass = cn('inline-flex items-center justify-center', className);
144
+
145
+ const icon = (
146
+ <EntryIcon
147
+ reference={reference}
148
+ id={refIdStr}
149
+ kind={iconKind}
150
+ tag={tag}
151
+ dataset={dataset}
152
+ size={size}
153
+ labeled={labeled}
154
+ label={label ?? (labeled ? name : undefined)}
155
+ missing={missing}
156
+ chip={variant === 'chip'}
157
+ preferVariant={preferVariant}
158
+ badge={badge}
159
+ badgeClassName={badgeClassName}
160
+ >
161
+ {children}
162
+ </EntryIcon>
163
+ );
164
+
165
+ let face: ReactElement = resolved ? (
166
+ <Link
167
+ href={resolved}
168
+ className={faceClass}
169
+ aria-label={name}
170
+ >
171
+ {icon}
172
+ </Link>
173
+ ) : (
174
+ <span className={faceClass}>{icon}</span>
175
+ );
176
+
177
+ const copyNbt = reference?.tag !== undefined ? formatNbt(reference.tag) : undefined;
178
+ const copyId =
179
+ reference && isTagKind(reference.kind)
180
+ ? `#${reference.id.replace(/^#+/, '')}`
181
+ : refIdStr;
182
+ if (copy && refIdStr) {
183
+ face = (
184
+ <CopyIdMenu id={copyId} nbt={copyNbt}>
185
+ {face}
186
+ </CopyIdMenu>
187
+ );
188
+ }
189
+
190
+ if (tooltip === false) return face;
191
+ const tooltipLevel = typeof tooltip === 'string' ? tooltip : undefined;
192
+ if (dataset && (tooltip || usesStackTooltip(refKind))) {
193
+ return (
194
+ <EntryTooltip
195
+ dataset={dataset}
196
+ id={reference ? refId(reference) : (id ?? '')}
197
+ name={name}
198
+ kind={tooltipLoadKind(refKind)}
199
+ lang={lang}
200
+ langMap={langMap}
201
+ level={tooltipLevel}
202
+ >
203
+ {face}
204
+ </EntryTooltip>
205
+ );
206
+ }
207
+ return (
208
+ <Tooltip>
209
+ <TooltipTrigger render={face} />
210
+ <TooltipContent>{name}</TooltipContent>
211
+ </Tooltip>
212
+ );
213
+ }
214
+
215
+ function kindEntry(kind: EntryProps['kind']) {
216
+ function KindEntry(props: KindEntryProps) {
217
+ return <Entry kind={kind} {...props} />;
218
+ }
219
+ return KindEntry;
220
+ }
221
+
222
+ export const ItemEntry = kindEntry('item');
223
+ export const BlockEntry = kindEntry('block');
224
+ export const FluidEntry = kindEntry('fluid');
225
+ export const EntityEntry = kindEntry('entity');
226
+ export const TagEntry = kindEntry('itemTag');
227
+ export const RecipeEntry = kindEntry('recipe');
228
+ export const QuestEntry = kindEntry('quest');
229
+ export const GuideEntry = kindEntry('guide');
@@ -0,0 +1,32 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
3
+
4
+ export const ENTRY_CHIP_CLASS =
5
+ 'inline-flex shrink-0 items-center justify-center rounded-sm border border-border bg-secondary p-0.5 hover:bg-accent';
6
+
7
+ export function EntryFrame({
8
+ size,
9
+ chip = false,
10
+ className,
11
+ style,
12
+ children,
13
+ }: {
14
+ size?: number;
15
+ chip?: boolean;
16
+ className?: string;
17
+ style?: React.CSSProperties;
18
+ children?: ReactNode;
19
+ }) {
20
+ return (
21
+ <span
22
+ className={cn(
23
+ 'inline-flex shrink-0 items-center justify-center',
24
+ chip && ENTRY_CHIP_CLASS,
25
+ className,
26
+ )}
27
+ style={{ ...(size ? { width: size, height: size } : undefined), ...style }}
28
+ >
29
+ {children}
30
+ </span>
31
+ );
32
+ }
@@ -0,0 +1,180 @@
1
+ 'use client';
2
+
3
+ import { EntryFrame } from '@terrafirmagreg-team/game/components/entry/frame';
4
+ import { EntryIcon, entrySizePx, ENTRY_CYCLE_MS } from '@terrafirmagreg-team/game/components/entry/icon';
5
+ import { useCycleIndex } from '@terrafirmagreg-team/ui/hooks/use-cycle-index';
6
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
7
+ import { fetchTagMembers } from '@terrafirmagreg-team/game/lib/entry/icon-load';
8
+ import {
9
+ classifyRefId,
10
+ isTagKind,
11
+ stackIdFromRef,
12
+ tagKindOf,
13
+ type ReferenceKind,
14
+ type TaggedReference,
15
+ } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
16
+ import type { EntrySize } from '@terrafirmagreg-team/game/types/entry';
17
+
18
+ const GAME_SLOT = 18;
19
+ const GAME_ICON = 12.8;
20
+ const STACK_SCALE = 1.15;
21
+ const FRAME_INSET = 6;
22
+ const POSITIONS: readonly (readonly [number, number])[][] = [
23
+ [],
24
+ [[1.6, 1.6]],
25
+ [
26
+ [0.4, 0.8],
27
+ [2.8, 2.4],
28
+ ],
29
+ [
30
+ [-0.8, 0],
31
+ [1.6, 1.6],
32
+ [4.0, 3.2],
33
+ ],
34
+ ];
35
+
36
+ function memberRef(
37
+ member: string | TaggedReference,
38
+ kind: ReferenceKind,
39
+ ): TaggedReference | undefined {
40
+ return typeof member === 'string' ? classifyRefId(member, kind) : member;
41
+ }
42
+
43
+ function memberKey(member: string | TaggedReference, kind: ReferenceKind): string {
44
+ const ref = memberRef(member, kind);
45
+ return ref ? `${ref.kind}:${stackIdFromRef(ref)}` : String(member);
46
+ }
47
+
48
+ async function expandTagRefs(
49
+ refs: readonly TaggedReference[],
50
+ dataset: string,
51
+ ): Promise<TaggedReference[]> {
52
+ const groups = await Promise.all(
53
+ refs.map((ref) =>
54
+ isTagKind(ref.kind)
55
+ ? fetchTagMembers(dataset, ref.id, tagKindOf(ref.kind) ?? 'item')
56
+ : null,
57
+ ),
58
+ );
59
+ const seen = new Set<string>();
60
+ const out: TaggedReference[] = [];
61
+ refs.forEach((ref, i) => {
62
+ const memberIds = groups[i];
63
+ const memberKind = tagKindOf(ref.kind) ?? 'item';
64
+ const expanded: TaggedReference[] = memberIds
65
+ ? memberIds.map((id) => ({ kind: memberKind, id }))
66
+ : [ref];
67
+ for (const member of expanded) {
68
+ const key = `${member.kind}:${stackIdFromRef(member)}`;
69
+ if (seen.has(key)) continue;
70
+ seen.add(key);
71
+ out.push(member);
72
+ }
73
+ });
74
+ return out;
75
+ }
76
+
77
+ export const GROUP_CYCLE_MS = ENTRY_CYCLE_MS;
78
+
79
+ export function EntryGroup({
80
+ members,
81
+ kind = 'item',
82
+ size = 36,
83
+ chip = true,
84
+ dataset,
85
+ limit = 3,
86
+ cycle,
87
+ missing = 'hide',
88
+ }: {
89
+ members: readonly (string | TaggedReference)[];
90
+ kind?: ReferenceKind;
91
+ size?: EntrySize;
92
+ chip?: boolean;
93
+ dataset?: string;
94
+ limit?: number;
95
+ cycle?: number;
96
+ missing?: 'placeholder' | 'item' | 'hide';
97
+ }) {
98
+ const sizePx = entrySizePx(size);
99
+ const references = members
100
+ .map((member) => memberRef(member, kind))
101
+ .filter((ref): ref is TaggedReference => ref !== undefined);
102
+ const expandKey =
103
+ cycle !== undefined &&
104
+ dataset &&
105
+ references.some((ref) => isTagKind(ref.kind))
106
+ ? `${dataset}|${references
107
+ .map((ref) => `${ref.kind}:${stackIdFromRef(ref)}`)
108
+ .join('|')}`
109
+ : null;
110
+ const expandedHit = useAsyncResult(
111
+ expandKey
112
+ ? async () => ({
113
+ key: expandKey,
114
+ refs: await expandTagRefs(references, dataset!),
115
+ })
116
+ : false,
117
+ [expandKey, dataset],
118
+ );
119
+ const cycleRefs = !expandKey
120
+ ? references
121
+ : expandedHit.value?.key === expandKey
122
+ ? expandedHit.value.refs
123
+ : references;
124
+ const [index] = useCycleIndex(cycleRefs.length, {
125
+ enabled: cycle !== undefined && cycle > 0,
126
+ intervalMs: cycle,
127
+ });
128
+ if (cycle !== undefined) {
129
+ return (
130
+ <span
131
+ className="relative block"
132
+ style={{ width: sizePx, height: sizePx }}
133
+ >
134
+ {cycleRefs.map((reference, i) => (
135
+ <span
136
+ key={`${reference.kind}:${stackIdFromRef(reference)}`}
137
+ className="absolute inset-0"
138
+ style={{ opacity: i === index ? 1 : 0 }}
139
+ aria-hidden={i !== index}
140
+ >
141
+ <EntryIcon
142
+ reference={reference}
143
+ dataset={dataset}
144
+ size={sizePx}
145
+ missing={missing}
146
+ />
147
+ </span>
148
+ ))}
149
+ </span>
150
+ );
151
+ }
152
+ const inner = sizePx - (chip ? FRAME_INSET : 0);
153
+ const scale = inner / GAME_SLOT;
154
+ const icons = members.slice(0, Math.min(limit, POSITIONS.length - 1));
155
+ const positions = POSITIONS[icons.length];
156
+ return (
157
+ <EntryFrame size={sizePx} chip={chip}>
158
+ <span className="relative block" style={{ width: inner, height: inner }}>
159
+ {icons.map((member, i) => (
160
+ <span
161
+ key={memberKey(member, kind)}
162
+ className="absolute"
163
+ style={{
164
+ left: positions[i][0] * STACK_SCALE * scale,
165
+ top: positions[i][1] * STACK_SCALE * scale,
166
+ zIndex: icons.length - i,
167
+ }}
168
+ >
169
+ <EntryIcon
170
+ reference={memberRef(member, kind)}
171
+ dataset={dataset}
172
+ size={GAME_ICON * STACK_SCALE * scale}
173
+ missing={missing}
174
+ />
175
+ </span>
176
+ ))}
177
+ </span>
178
+ </EntryFrame>
179
+ );
180
+ }