@terrafirmagreg-team/game 0.0.0-stage → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,62 @@
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": "1.2.0",
4
+ "type": "module",
5
+ "description": "Game-data components and catalogs",
6
+ "license": "LGPL-3.0-only",
7
+ "exports": {
8
+ "./components/*": [
9
+ "./src/components/*.tsx",
10
+ "./src/components/*/index.tsx",
11
+ "./src/components/*/index.ts"
12
+ ],
13
+ "./hooks/*": [
14
+ "./src/hooks/*.ts",
15
+ "./src/hooks/*/index.ts"
16
+ ],
17
+ "./lib/*": [
18
+ "./src/lib/*.ts",
19
+ "./src/lib/*/index.ts"
20
+ ],
21
+ "./types/*": [
22
+ "./src/types/*.ts",
23
+ "./src/types/*/index.ts"
24
+ ],
25
+ "./styles.css": "./styles.css",
26
+ "./locales": "./src/locales.ts"
27
+ },
28
+ "dependencies": {
29
+ "@terrafirmagreg-team/data": "1.2.0",
30
+ "@terrafirmagreg-team/dump": "1.2.0",
31
+ "@terrafirmagreg-team/kit": "1.2.0",
32
+ "@terrafirmagreg-team/search": "1.2.0",
33
+ "@terrafirmagreg-team/sites": "1.2.0",
34
+ "@terrafirmagreg-team/translate": "1.2.0",
35
+ "@terrafirmagreg-team/ui": "1.2.0",
36
+ "fumadocs-core": "16.17.8",
37
+ "lucide-react": "^0.577.0",
38
+ "server-only": "^0.0.1"
39
+ },
40
+ "peerDependencies": {
41
+ "next": ">=15",
42
+ "react": ">=19"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^26.6.5",
46
+ "@types/react": "^19.3.0",
47
+ "@types/react-dom": "^19.3.0"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
52
+ "directory": "packages/game"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "files": [
58
+ "src",
59
+ "styles",
60
+ "styles.css"
61
+ ]
6
62
  }
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
3
+
4
+ import { atlasPagePublicUrl } from '@terrafirmagreg-team/dump/lib/dump/assets/atlas/lookup';
5
+ import type { AtlasLookup } from '@terrafirmagreg-team/dump/lib/dump/assets/atlas/dto';
6
+ import { Sprite } from '@terrafirmagreg-team/ui/components/blocks/sprite';
7
+ import { Skeleton } from '@terrafirmagreg-team/ui/components/ui/skeleton';
8
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
9
+
10
+ export function AtlasSprite({
11
+ dataset,
12
+ lookup,
13
+ size = 16,
14
+ className,
15
+ }: {
16
+ dataset: DatasetRef;
17
+ lookup: AtlasLookup | null;
18
+ size?: number;
19
+ className?: string;
20
+ }) {
21
+ const url = lookup
22
+ ? atlasPagePublicUrl(dataset, lookup.atlas, lookup.page)
23
+ : undefined;
24
+ return (
25
+ <Sprite
26
+ src={url}
27
+ rect={lookup ? lookup.sprite : undefined}
28
+ page={lookup ? lookup.page : undefined}
29
+ tint={
30
+ lookup?.sprite.tintColor && lookup.sprite.tint !== 'none'
31
+ ? lookup.sprite.tintColor
32
+ : undefined
33
+ }
34
+ size={size}
35
+ className={className}
36
+ fallback={
37
+ <Skeleton
38
+ className={cn('inline-block shrink-0', className)}
39
+ style={{ width: size, height: size }}
40
+ />
41
+ }
42
+ />
43
+ );
44
+ }
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import { Copy } from 'lucide-react';
4
+ import type { ReactNode } from 'react';
5
+ import { CopyMenu } from '@terrafirmagreg-team/ui/components/blocks/nav/copy-menu';
6
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
7
+
8
+ export function CopyIdMenu({
9
+ id,
10
+ nbt,
11
+ label,
12
+ extra,
13
+ children,
14
+ }: {
15
+ id: string;
16
+
17
+ nbt?: string;
18
+
19
+ label?: string;
20
+
21
+ extra?: ReactNode;
22
+ children: ReactNode;
23
+ }) {
24
+ const t = useTranslations();
25
+ return (
26
+ <CopyMenu
27
+ items={[
28
+ {
29
+ icon: <Copy />,
30
+ label: label ?? t('Copy id', { note: 'ui.entry.copy' }),
31
+ value: id,
32
+ },
33
+ ...(nbt
34
+ ? [
35
+ {
36
+ label: t('Copy stack id', { note: 'ui.entry.copy_stack' }),
37
+ value: `${id}${nbt}`,
38
+ },
39
+ { label: t('Copy NBT', { note: 'ui.entry.copy_nbt' }), value: nbt },
40
+ ]
41
+ : []),
42
+ ]}
43
+ extra={extra}
44
+ >
45
+ {children}
46
+ </CopyMenu>
47
+ );
48
+ }
@@ -0,0 +1,308 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { usePathname } from 'next/navigation';
5
+ import { parseSectionLocation } from '@terrafirmagreg-team/sites/location';
6
+ import type { ReactElement } from 'react';
7
+ import { EntryIcon } from '@terrafirmagreg-team/game/components/entry/icon';
8
+ import { EntryTooltip } from '@terrafirmagreg-team/game/components/entry/tooltip';
9
+ import { CopyIdMenu } from '@terrafirmagreg-team/game/components/entry/copy-id-menu';
10
+ import { useEntryContext } from '@terrafirmagreg-team/game/hooks/use-entry-context';
11
+ import { useLangMap } from '@terrafirmagreg-team/game/hooks/use-lang-map';
12
+ import { ResolvedLangContext } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
13
+ import { useContext, useMemo } from 'react';
14
+ import {
15
+ Tooltip,
16
+ TooltipContent,
17
+ TooltipTrigger,
18
+ } from '@terrafirmagreg-team/ui/components/ui/tooltip';
19
+ import {
20
+ classifyRefId,
21
+ isIconKind,
22
+ isTagKind,
23
+ refId,
24
+ } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
25
+ import { formatNbt } from '@terrafirmagreg-team/dump/lib/snbt';
26
+ import { fetchEntityModel } from '@terrafirmagreg-team/game/lib/entry/icon-load';
27
+ import { useDumpLocation } from '@terrafirmagreg-team/game/hooks/use-dump-location';
28
+ import { createDumpAssetResolver } from '@terrafirmagreg-team/dump/lib/dump/assets/urls';
29
+ import { GlbViewer } from '@terrafirmagreg-team/ui/components/blocks/media/glb/viewer';
30
+ import { stackIdFromRef } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
31
+ import { stackDisplay } from '@terrafirmagreg-team/game/lib/entry/display';
32
+ import { loadStackDisplay } from '@terrafirmagreg-team/game/lib/entry/tooltip';
33
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
34
+ import {
35
+ resolveEntryHref,
36
+ tooltipLoadKind,
37
+ usesStackTooltip,
38
+ } from '@terrafirmagreg-team/game/lib/entry/entry';
39
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
40
+ import type {
41
+ BlockDirectiveProps,
42
+ EntityDirectiveProps,
43
+ EntryProps,
44
+ FluidDirectiveProps,
45
+ ItemDirectiveProps,
46
+ KindEntryProps,
47
+ RecipeDirectiveProps,
48
+ TagDirectiveProps,
49
+ } from '@terrafirmagreg-team/game/types/entry';
50
+
51
+ export function Entry({
52
+ reference: referenceProp,
53
+ id,
54
+ kind,
55
+ tag,
56
+ copy,
57
+ dataset: datasetProp,
58
+ name: nameProp,
59
+ lang: langProp,
60
+ langMap: langMapProp,
61
+ size = 'md',
62
+ variant = 'plain',
63
+ badge,
64
+ badgeClassName,
65
+ labeled = false,
66
+ label,
67
+ className,
68
+ missing,
69
+ tooltip,
70
+ children,
71
+ entityView,
72
+ icon: iconProp,
73
+ ...hrefOpts
74
+ }: EntryProps) {
75
+ const ctx = useEntryContext();
76
+ const pathname = usePathname();
77
+ const locId = parseSectionLocation(pathname).dataset;
78
+ const dataset =
79
+ datasetProp ?? ctx?.dataset ?? (locId ? { id: locId } : undefined);
80
+ const resolvedLang = useContext(ResolvedLangContext)?.lang;
81
+ const lang = langProp ?? ctx?.lang ?? resolvedLang;
82
+ const fetchedLangMap = useLangMap(
83
+ dataset,
84
+ lang,
85
+ langMapProp === undefined && !ctx?.langMap,
86
+ );
87
+ const langMap = langMapProp ?? ctx?.langMap ?? fetchedLangMap;
88
+ const classified = id ? classifyRefId(id, kind) : undefined;
89
+ const reference =
90
+ referenceProp ??
91
+ (classified && tag !== undefined ? { ...classified, tag } : classified);
92
+ const refKind = reference?.kind;
93
+ const refIdStr = reference?.id ?? id ?? '';
94
+ const iconKind = refKind && isIconKind(refKind) ? refKind : undefined;
95
+ const stackId = reference ? stackIdFromRef(reference) : refIdStr;
96
+ const displayKey =
97
+ dataset &&
98
+ stackId &&
99
+ (refKind === 'item' ||
100
+ refKind === 'block' ||
101
+ refKind === 'fluid' ||
102
+ refKind === 'entity')
103
+ ? `${dataset.id}/${dataset.version ?? ''}:${stackId}`
104
+ : undefined;
105
+ const displayPeek =
106
+ displayKey && dataset
107
+ ? loadStackDisplay.peek(
108
+ dataset,
109
+ stackId,
110
+ refKind === 'fluid'
111
+ ? 'fluid'
112
+ : refKind === 'entity'
113
+ ? 'entity'
114
+ : 'item',
115
+ )
116
+ : undefined;
117
+ const stackDisplayRes = useAsyncResult(
118
+ displayKey && dataset && displayPeek === undefined
119
+ ? async () => ({
120
+ key: displayKey,
121
+ display: await loadStackDisplay(
122
+ dataset,
123
+ stackId,
124
+ refKind === 'fluid'
125
+ ? 'fluid'
126
+ : refKind === 'entity'
127
+ ? 'entity'
128
+ : 'item',
129
+ ),
130
+ })
131
+ : false,
132
+ [dataset, stackId, displayKey, refKind, displayPeek],
133
+ );
134
+ const stackMissing =
135
+ displayKey !== undefined &&
136
+ displayPeek === undefined &&
137
+ !stackDisplayRes.pending &&
138
+ stackDisplayRes.value?.key === displayKey &&
139
+ stackDisplayRes.value.display === undefined;
140
+ const langKey =
141
+ displayPeek !== undefined
142
+ ? displayPeek?.name
143
+ : stackDisplayRes.value && stackDisplayRes.value.key === displayKey
144
+ ? stackDisplayRes.value.display?.name
145
+ : undefined;
146
+ const display = stackDisplay(langMap, stackId, refKind, langKey);
147
+ const name = nameProp ?? display.name;
148
+ const preferVariant =
149
+ reference?.tag === undefined &&
150
+ (refKind === 'item' || refKind === 'block' || refKind === 'fluid') &&
151
+ langMap !== undefined &&
152
+ !display.localized;
153
+ const resolved =
154
+ dataset && !stackMissing
155
+ ? resolveEntryHref({
156
+ reference,
157
+ id: refIdStr,
158
+ kind: refKind,
159
+ tag,
160
+ dataset,
161
+ name,
162
+ lang,
163
+ ...hrefOpts,
164
+ })
165
+ : undefined;
166
+ const faceClass = cn('inline-flex items-center justify-center', className);
167
+
168
+ const icon = (
169
+ <EntryIcon
170
+ reference={iconProp ?? reference}
171
+ id={iconProp ? refId(iconProp) : refIdStr}
172
+ kind={
173
+ iconProp
174
+ ? isIconKind(iconProp.kind)
175
+ ? iconProp.kind
176
+ : undefined
177
+ : iconKind
178
+ }
179
+ tag={tag}
180
+ dataset={dataset}
181
+ size={size}
182
+ labeled={labeled}
183
+ label={label ?? (labeled ? name : undefined)}
184
+ missing={missing}
185
+ chip={variant === 'chip'}
186
+ preferVariant={preferVariant}
187
+ entityView={entityView}
188
+ badge={badge}
189
+ badgeClassName={badgeClassName}
190
+ >
191
+ {children}
192
+ </EntryIcon>
193
+ );
194
+
195
+ let face: ReactElement = resolved ? (
196
+ <Link
197
+ href={resolved}
198
+ className={faceClass}
199
+ aria-label={name}
200
+ >
201
+ {icon}
202
+ </Link>
203
+ ) : (
204
+ <span className={faceClass}>{icon}</span>
205
+ );
206
+
207
+ const copyNbt = reference?.tag !== undefined ? formatNbt(reference.tag) : undefined;
208
+ const copyId =
209
+ reference && isTagKind(reference.kind)
210
+ ? `#${reference.id.replace(/^#+/, '')}`
211
+ : refIdStr;
212
+ if (copy && refIdStr) {
213
+ face = (
214
+ <CopyIdMenu id={copyId} nbt={copyNbt}>
215
+ {face}
216
+ </CopyIdMenu>
217
+ );
218
+ }
219
+
220
+ if (tooltip === false) return face;
221
+ const tooltipLevel = typeof tooltip === 'string' ? tooltip : undefined;
222
+ if (dataset && (tooltip || usesStackTooltip(refKind))) {
223
+ return (
224
+ <EntryTooltip
225
+ dataset={dataset}
226
+ id={reference ? refId(reference) : (id ?? '')}
227
+ name={name}
228
+ kind={tooltipLoadKind(refKind)}
229
+ lang={lang}
230
+ langMap={langMap}
231
+ level={tooltipLevel}
232
+ >
233
+ {face}
234
+ </EntryTooltip>
235
+ );
236
+ }
237
+ return (
238
+ <Tooltip>
239
+ <TooltipTrigger render={face} />
240
+ <TooltipContent>{name}</TooltipContent>
241
+ </Tooltip>
242
+ );
243
+ }
244
+
245
+ function kindEntry<P extends KindEntryProps>(kind: EntryProps['kind']) {
246
+ function KindEntry(props: P) {
247
+ return <Entry kind={kind} {...props} />;
248
+ }
249
+ return KindEntry;
250
+ }
251
+
252
+ export const ItemEntry = kindEntry<ItemDirectiveProps>('item');
253
+ export const BlockEntry = kindEntry<BlockDirectiveProps>('block');
254
+ export const FluidEntry = kindEntry<FluidDirectiveProps>('fluid');
255
+ export function EntityEntry({
256
+ view = 'auto',
257
+ id,
258
+ dataset: datasetProp,
259
+ name,
260
+ ...rest
261
+ }: EntityDirectiveProps) {
262
+ const ctx = useEntryContext();
263
+ const pathname = usePathname();
264
+ const locId = parseSectionLocation(pathname).dataset;
265
+ const dataset = useMemo(
266
+ () =>
267
+ datasetProp ?? ctx?.dataset ?? (locId ? { id: locId } : undefined),
268
+ [datasetProp, ctx, locId],
269
+ );
270
+ const modelKey = dataset && id ? `${dataset.id}/${dataset.version ?? ''}:${id}` : undefined;
271
+ const modelRes = useAsyncResult(
272
+ view === 'model' && modelKey
273
+ ? async () => ({
274
+ key: modelKey,
275
+ url: await fetchEntityModel(dataset!, id!),
276
+ })
277
+ : false,
278
+ [view, dataset, id, modelKey],
279
+ );
280
+ useDumpLocation(dataset);
281
+ const resolveResource = useMemo(
282
+ () => (dataset ? createDumpAssetResolver(dataset) : undefined),
283
+ [dataset],
284
+ );
285
+ if (view === 'model') {
286
+ const model = modelRes.value;
287
+ const src = model && model.key === modelKey ? model.url : undefined;
288
+ if (src === undefined) return null;
289
+ return src ? (
290
+ <GlbViewer src={src} title={name ?? id} resolveResource={resolveResource} />
291
+ ) : (
292
+ <Entry kind="entity" id={id} dataset={dataset} name={name} {...rest} />
293
+ );
294
+ }
295
+ return (
296
+ <Entry
297
+ kind="entity"
298
+ id={id}
299
+ dataset={dataset}
300
+ name={name}
301
+ entityView={view}
302
+ {...rest}
303
+ />
304
+ );
305
+ }
306
+ export const TagEntry = kindEntry<TagDirectiveProps>('itemTag');
307
+ export const RecipeEntry = kindEntry<RecipeDirectiveProps>('recipe');
308
+
@@ -0,0 +1,161 @@
1
+ 'use client';
2
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
3
+
4
+ import {
5
+ SPRITE_CLUSTER_INSET,
6
+ SPRITE_CLUSTER_UNITS,
7
+ SpriteCluster,
8
+ } from '@terrafirmagreg-team/ui/components/blocks/sprite';
9
+ import { EntryIcon, entrySizePx, ENTRY_CYCLE_MS } from '@terrafirmagreg-team/game/components/entry/icon';
10
+ import { useCycleIndex } from '@terrafirmagreg-team/ui/hooks/use-cycle-index';
11
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
12
+ import { fetchTagMembers } from '@terrafirmagreg-team/game/lib/entry/icon-load';
13
+ import {
14
+ classifyRefId,
15
+ isTagKind,
16
+ stackIdFromRef,
17
+ tagKindOf,
18
+ type ReferenceKind,
19
+ type TaggedReference,
20
+ } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
21
+ import type { EntrySize } from '@terrafirmagreg-team/game/types/entry';
22
+
23
+ const GAME_ICON = 12.8;
24
+ const STACK_SCALE = 1.15;
25
+
26
+ function memberRef(
27
+ member: string | TaggedReference,
28
+ kind: ReferenceKind,
29
+ ): TaggedReference | undefined {
30
+ return typeof member === 'string' ? classifyRefId(member, kind) : member;
31
+ }
32
+
33
+ function memberKey(member: string | TaggedReference, kind: ReferenceKind): string {
34
+ const ref = memberRef(member, kind);
35
+ return ref ? `${ref.kind}:${stackIdFromRef(ref)}` : String(member);
36
+ }
37
+
38
+ async function expandTagRefs(
39
+ refs: readonly TaggedReference[],
40
+ dataset: DatasetRef,
41
+ ): Promise<TaggedReference[]> {
42
+ const groups = await Promise.all(
43
+ refs.map((ref) =>
44
+ isTagKind(ref.kind)
45
+ ? fetchTagMembers(dataset, ref.id, tagKindOf(ref.kind) ?? 'item')
46
+ : null,
47
+ ),
48
+ );
49
+ const seen = new Set<string>();
50
+ const out: TaggedReference[] = [];
51
+ refs.forEach((ref, i) => {
52
+ const memberIds = groups[i];
53
+ const memberKind = tagKindOf(ref.kind) ?? 'item';
54
+ const expanded: TaggedReference[] = memberIds
55
+ ? memberIds.map((id) => ({ kind: memberKind, id }))
56
+ : [ref];
57
+ for (const member of expanded) {
58
+ const key = `${member.kind}:${stackIdFromRef(member)}`;
59
+ if (seen.has(key)) continue;
60
+ seen.add(key);
61
+ out.push(member);
62
+ }
63
+ });
64
+ return out;
65
+ }
66
+
67
+ export const GROUP_CYCLE_MS = ENTRY_CYCLE_MS;
68
+
69
+ export function EntryGroup({
70
+ members,
71
+ kind = 'item',
72
+ size = 36,
73
+ chip = true,
74
+ dataset,
75
+ limit = 3,
76
+ cycle,
77
+ missing = 'hide',
78
+ }: {
79
+ members: readonly (string | TaggedReference)[];
80
+ kind?: ReferenceKind;
81
+ size?: EntrySize;
82
+ chip?: boolean;
83
+ dataset?: DatasetRef;
84
+ limit?: number;
85
+ cycle?: number;
86
+ missing?: 'placeholder' | 'item' | 'hide';
87
+ }) {
88
+ const sizePx = entrySizePx(size);
89
+ const references = members
90
+ .map((member) => memberRef(member, kind))
91
+ .filter((ref): ref is TaggedReference => ref !== undefined);
92
+ const expandKey =
93
+ cycle !== undefined &&
94
+ dataset &&
95
+ references.some((ref) => isTagKind(ref.kind))
96
+ ? `${dataset.id}/${dataset.version ?? ''}|${references
97
+ .map((ref) => `${ref.kind}:${stackIdFromRef(ref)}`)
98
+ .join('|')}`
99
+ : null;
100
+ const expandedHit = useAsyncResult(
101
+ expandKey
102
+ ? async () => ({
103
+ key: expandKey,
104
+ refs: await expandTagRefs(references, dataset!),
105
+ })
106
+ : false,
107
+ [expandKey, dataset],
108
+ );
109
+ const cycleRefs = !expandKey
110
+ ? references
111
+ : expandedHit.value?.key === expandKey
112
+ ? expandedHit.value.refs
113
+ : references;
114
+ const [index] = useCycleIndex(cycleRefs.length, {
115
+ enabled: cycle !== undefined && cycle > 0,
116
+ intervalMs: cycle,
117
+ });
118
+ if (cycle !== undefined) {
119
+ return (
120
+ <span
121
+ className="relative block"
122
+ style={{ width: sizePx, height: sizePx }}
123
+ >
124
+ {cycleRefs.map((reference, i) => (
125
+ <span
126
+ key={`${reference.kind}:${stackIdFromRef(reference)}`}
127
+ className="absolute inset-0"
128
+ style={{ opacity: i === index ? 1 : 0 }}
129
+ aria-hidden={i !== index}
130
+ >
131
+ <EntryIcon
132
+ reference={reference}
133
+ dataset={dataset}
134
+ size={sizePx}
135
+ missing={missing}
136
+ />
137
+ </span>
138
+ ))}
139
+ </span>
140
+ );
141
+ }
142
+ const scale =
143
+ (sizePx - (chip ? SPRITE_CLUSTER_INSET : 0)) / SPRITE_CLUSTER_UNITS;
144
+ return (
145
+ <SpriteCluster
146
+ size={sizePx}
147
+ chip={chip}
148
+ limit={limit}
149
+ items={members.slice(0, limit).map((member) => (
150
+ <EntryIcon
151
+ key={memberKey(member, kind)}
152
+ reference={memberRef(member, kind)}
153
+ dataset={dataset}
154
+ size={GAME_ICON * STACK_SCALE * scale}
155
+ missing={missing}
156
+ />
157
+ ))}
158
+ />
159
+ );
160
+ }
161
+