@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
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import { useRef, useState, type ReactNode } from 'react';
4
+ import { useElementSize } from '@terrafirmagreg-team/ui/hooks/use-element-size';
5
+ import { useSwipe } from '@terrafirmagreg-team/ui/hooks/use-swipe';
6
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
7
+ import {
8
+ Tooltip,
9
+ TooltipContent,
10
+ TooltipTrigger,
11
+ } from '@terrafirmagreg-team/ui/components/ui/tooltip';
12
+ import { PagerArrow } from '@terrafirmagreg-team/ui/components/blocks/page/pager';
13
+ import { ENTRY_CHIP_CLASS } from '@terrafirmagreg-team/game/components/entry/frame';
14
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
15
+
16
+ type IconTabOption<T extends string> = {
17
+ id: T;
18
+ icon: ReactNode;
19
+ help?: string;
20
+ };
21
+
22
+ const CELL = 40;
23
+ const GAP = 4;
24
+ const ARROW = 36;
25
+
26
+ export function IconTabs<T extends string>({
27
+ value,
28
+ options,
29
+ onChange,
30
+ className,
31
+ }: {
32
+ value?: T;
33
+ options: IconTabOption<T>[];
34
+ onChange: (id: T) => void;
35
+ className?: string;
36
+ }) {
37
+ const t = useTranslations();
38
+ const stripRef = useRef<HTMLDivElement | null>(null);
39
+ const { w } = useElementSize(stripRef);
40
+ const per = Math.max(
41
+ 1,
42
+ Math.floor((w - 2 * (ARROW + GAP) + GAP) / CELL),
43
+ );
44
+ const pages = Math.max(1, Math.ceil(options.length / per));
45
+ const valueIndex = options.findIndex((option) => option.id === value);
46
+ const autoPage = Math.max(0, Math.floor(valueIndex / per));
47
+ const [manual, setManual] = useState<number | null>(null);
48
+ const [lastValue, setLastValue] = useState(value);
49
+ if (lastValue !== value) {
50
+ setLastValue(value);
51
+ setManual(null);
52
+ }
53
+ const page = Math.min(manual ?? autoPage, pages - 1);
54
+ const shown = options.slice(page * per, (page + 1) * per);
55
+
56
+ const go = (dir: number) => setManual((page + dir + pages) % pages);
57
+ const swipe = useSwipe((dir) => {
58
+ const next = valueIndex + dir;
59
+ if (next >= 0 && next < options.length) {
60
+ onChange(options[next]!.id);
61
+ } else {
62
+ go(dir);
63
+ }
64
+ });
65
+
66
+ const arrow = (dir: -1 | 1, label: string) => (
67
+ <PagerArrow
68
+ dir={dir}
69
+ label={label}
70
+ disabled={pages <= 1}
71
+ onClick={() => go(dir)}
72
+ />
73
+ );
74
+
75
+ return (
76
+ <div
77
+ ref={stripRef}
78
+ {...swipe}
79
+ onWheel={(event) => {
80
+ if (pages > 1) go(event.deltaY > 0 ? 1 : -1);
81
+ }}
82
+ className={cn('flex min-h-9 items-center gap-1', className)}
83
+ >
84
+ {arrow(-1, t('ui.prev_page'))}
85
+ <div
86
+ className={cn(
87
+ 'flex min-w-0 flex-1 items-center justify-center gap-1',
88
+ w === 0 && 'invisible',
89
+ )}
90
+ >
91
+ {shown.map((option) => (
92
+ <Tooltip key={option.id}>
93
+ <TooltipTrigger
94
+ render={
95
+ <button
96
+ type="button"
97
+ onClick={() => onChange(option.id)}
98
+ aria-label={option.help ?? option.id}
99
+ aria-pressed={option.id === value}
100
+ className={cn(
101
+ ENTRY_CHIP_CLASS,
102
+ 'size-9 rounded-sm p-0 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-pressed:border-primary aria-pressed:bg-accent aria-pressed:text-foreground',
103
+ )}
104
+ />
105
+ }
106
+ >
107
+ {option.icon}
108
+ </TooltipTrigger>
109
+ {option.help ? (
110
+ <TooltipContent>{option.help}</TooltipContent>
111
+ ) : null}
112
+ </Tooltip>
113
+ ))}
114
+ </div>
115
+ {arrow(1, t('ui.next_page'))}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,187 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useEntryContext } from '@terrafirmagreg-team/game/hooks/use-entry-context';
5
+ import { EntryFrame } from '@terrafirmagreg-team/game/components/entry/frame';
6
+ import { AtlasSprite } from '@terrafirmagreg-team/game/components/entry/atlas-sprite';
7
+ import { useIconSource } from '@terrafirmagreg-team/game/hooks/use-icon-source';
8
+ import { Skeleton } from '@terrafirmagreg-team/ui/components/ui/skeleton';
9
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
10
+ import type {
11
+ DumpNbt,
12
+ ReferenceKind,
13
+ TaggedReference,
14
+ } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
15
+ import type { EntrySize } from '@terrafirmagreg-team/game/types/entry';
16
+
17
+ export { ENTRY_CYCLE_MS } from '@terrafirmagreg-team/game/hooks/use-icon-source';
18
+
19
+ const ENTRY_SIZE_PX = { sm: 16, md: 24, lg: 32, xl: 40 } as const;
20
+
21
+ export function entrySizePx(size: EntrySize): number {
22
+ return typeof size === 'number' ? size : ENTRY_SIZE_PX[size];
23
+ }
24
+
25
+ export function EntryIcon({
26
+ reference,
27
+ id,
28
+ kind,
29
+ tag,
30
+ dataset: datasetProp,
31
+ size: sizeProp = 'md',
32
+ labeled = false,
33
+ label,
34
+ missing = 'placeholder',
35
+ chip = false,
36
+ preferVariant = false,
37
+ badge,
38
+ badgeClassName,
39
+ children,
40
+ }: {
41
+
42
+ reference?: TaggedReference;
43
+ id?: string;
44
+ kind?: ReferenceKind;
45
+ tag?: DumpNbt;
46
+ dataset?: string;
47
+ size?: EntrySize;
48
+ labeled?: boolean;
49
+ label?: string;
50
+ missing?: 'placeholder' | 'item' | 'hide';
51
+ chip?: boolean;
52
+
53
+ preferVariant?: boolean;
54
+ badge?: React.ReactNode;
55
+ badgeClassName?: string;
56
+ children?: React.ReactNode;
57
+ }) {
58
+ const size = entrySizePx(sizeProp);
59
+ const ctx = useEntryContext();
60
+ const dataset = datasetProp ?? ctx?.dataset;
61
+ const source = useIconSource({
62
+ dataset,
63
+ reference,
64
+ id,
65
+ kind,
66
+ tag,
67
+ missing,
68
+ preferVariant,
69
+ });
70
+ const caption = label || undefined;
71
+ const name = caption ?? source.rawId;
72
+ const [fileFailed, setFileFailed] = useState<{
73
+ key: string;
74
+ failed: boolean;
75
+ }>({ key: '', failed: false });
76
+ const hideMissing = missing === 'hide';
77
+ const imgSrc = source.imgSrc;
78
+
79
+ const failed =
80
+ fileFailed.failed && fileFailed.key === (imgSrc ?? source.sourceKey);
81
+
82
+ const boxStyle = { width: size, height: size };
83
+ const pendingBox = (
84
+ <Skeleton
85
+ className="inline-block shrink-0 rounded-sm"
86
+ style={boxStyle}
87
+ />
88
+ );
89
+ const missingBox = (
90
+ <span
91
+ className="font-mc inline-flex shrink-0 items-center justify-center rounded-sm border border-border bg-secondary text-muted-foreground"
92
+ style={{ ...boxStyle, fontSize: Math.max(7, Math.round(size * 0.6)) }}
93
+ >
94
+ ?
95
+ </span>
96
+ );
97
+ const img = (src: string) => (
98
+ // eslint-disable-next-line @next/next/no-img-element
99
+ <img
100
+ src={src}
101
+ alt={name}
102
+ width={size}
103
+ height={size}
104
+ className="rounded-sm"
105
+ style={{ width: size, height: size, imageRendering: 'pixelated' }}
106
+ loading="lazy"
107
+ decoding="async"
108
+ onError={() =>
109
+ setFileFailed({ key: imgSrc ?? source.sourceKey, failed: true })
110
+ }
111
+ />
112
+ );
113
+
114
+ let icon: React.ReactNode = null;
115
+ if (source.cycleRef && dataset) {
116
+ icon = (
117
+ <EntryIcon
118
+ reference={source.cycleRef}
119
+ dataset={dataset}
120
+ size={size}
121
+ missing={missing}
122
+ />
123
+ );
124
+ } else if (source.lookup && dataset) {
125
+ icon = <AtlasSprite dataset={dataset} lookup={source.lookup} size={size} />;
126
+ } else if (source.pending || source.awaitingAtlas) {
127
+ icon = hideMissing ? null : pendingBox;
128
+ } else if (source.isEntity && imgSrc && !failed) {
129
+ icon = img(imgSrc);
130
+ } else if (source.awaitingTexture) {
131
+ icon = hideMissing ? null : pendingBox;
132
+ } else if (source.usesAtlas || !imgSrc || failed) {
133
+ icon = hideMissing
134
+ ? null
135
+ : missing === 'item' && source.missingLookup && dataset
136
+ ? (
137
+ <AtlasSprite
138
+ dataset={dataset}
139
+ lookup={source.missingLookup}
140
+ size={size}
141
+ />
142
+ )
143
+ : missing === 'item' && source.missingPending
144
+ ? pendingBox
145
+ : missingBox;
146
+ } else {
147
+ icon = img(imgSrc);
148
+ }
149
+
150
+ if (!icon && !labeled && !children) return null;
151
+
152
+ const framed = (
153
+ <EntryFrame
154
+ size={size}
155
+ chip={chip}
156
+ className={badge !== undefined ? 'relative' : undefined}
157
+ >
158
+ {icon}
159
+ {badge !== undefined ? (
160
+ <span
161
+ className={cn(
162
+ 'pointer-events-none absolute -right-0.5 -bottom-0.5 z-10',
163
+ badgeClassName,
164
+ )}
165
+ >
166
+ {badge}
167
+ </span>
168
+ ) : null}
169
+ </EntryFrame>
170
+ );
171
+
172
+ if (!labeled && !children) return framed;
173
+
174
+ return (
175
+ <span className="inline-flex items-center gap-2 align-middle not-prose">
176
+ {framed}
177
+ {labeled ? (
178
+ caption && caption !== source.rawId ? (
179
+ <span className="text-xs">{caption}</span>
180
+ ) : (
181
+ <code className="text-xs">{source.rawId}</code>
182
+ )
183
+ ) : null}
184
+ {children}
185
+ </span>
186
+ );
187
+ }
@@ -0,0 +1,70 @@
1
+ 'use client';
2
+
3
+ import { Entry } from '@terrafirmagreg-team/game/components/entry/entry';
4
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
5
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
6
+ import type { TaggedReference } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
7
+
8
+ export function EntryList({
9
+ items,
10
+ dataset,
11
+ lang,
12
+ langMap,
13
+ tab,
14
+ type,
15
+ variant = 'grid',
16
+ labeled,
17
+ href,
18
+ className,
19
+ }: {
20
+ items: TaggedReference[];
21
+ dataset?: string;
22
+ lang?: Lang;
23
+ langMap?: Record<string, string>;
24
+ tab?: string;
25
+ type?: string;
26
+ variant?: 'grid' | 'row';
27
+
28
+ labeled?: boolean;
29
+
30
+ href?: string | false;
31
+ className?: string;
32
+ }) {
33
+ return (
34
+ <ul
35
+ className={cn(
36
+ variant === 'grid'
37
+ ? 'grid grid-cols-[repeat(auto-fill,minmax(--spacing(9),1fr))] gap-1'
38
+ : 'flex flex-wrap items-center gap-3',
39
+ className,
40
+ )}
41
+ >
42
+ {items.map((item, index) => (
43
+ <li key={`${item.kind}:${item.id}:${index}`}>
44
+ {variant === 'grid' ? (
45
+ <Entry
46
+ reference={item}
47
+ dataset={dataset}
48
+ lang={lang}
49
+ langMap={langMap}
50
+ tab={tab}
51
+ type={type}
52
+ href={href}
53
+ variant="chip"
54
+ size="lg"
55
+ className="size-9"
56
+ />
57
+ ) : (
58
+ <Entry
59
+ reference={item}
60
+ dataset={dataset}
61
+ langMap={langMap}
62
+ labeled={labeled}
63
+ href={href}
64
+ />
65
+ )}
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ );
70
+ }
@@ -0,0 +1,92 @@
1
+ import { Fragment, type CSSProperties } from 'react';
2
+ import { MC_FORMAT_COLORS } from '@terrafirmagreg-team/game/lib/dump/contract/text';
3
+
4
+ const isCode = (char: string | undefined): char is string =>
5
+ char !== undefined && /[0-9a-fklmnor]/.test(char);
6
+
7
+ type Run = { text: string; style?: CSSProperties };
8
+
9
+ export function McText({ text }: { text: string }) {
10
+ const runs: Run[] = [];
11
+ let style: CSSProperties | undefined;
12
+ let buf = '';
13
+ const flush = () => {
14
+ if (!buf) return;
15
+ runs.push(style ? { text: buf, style } : { text: buf });
16
+ buf = '';
17
+ };
18
+ for (let i = 0; i < text.length; i += 1) {
19
+ const ch = text[i];
20
+ if (ch !== '§') {
21
+ buf += ch;
22
+ continue;
23
+ }
24
+ const code = text[i + 1]?.toLowerCase();
25
+ if (code === 'x') {
26
+ let hex = '';
27
+ let j = i + 2;
28
+ while (
29
+ hex.length < 6 &&
30
+ text[j] === '§' &&
31
+ /^[0-9a-f]$/.test(text[j + 1]?.toLowerCase() ?? '')
32
+ ) {
33
+ hex += (text[j + 1] ?? '').toLowerCase();
34
+ j += 2;
35
+ }
36
+ if (hex.length === 6) {
37
+ flush();
38
+ i = j - 1;
39
+ const next: CSSProperties = { ...(style ?? {}), color: `#${hex}` };
40
+ delete next.fontWeight;
41
+ delete next.fontStyle;
42
+ delete next.textDecoration;
43
+ style = next;
44
+ continue;
45
+ }
46
+ buf += ch;
47
+ continue;
48
+ }
49
+ if (!isCode(code)) {
50
+ buf += ch;
51
+ continue;
52
+ }
53
+ flush();
54
+ i += 1;
55
+ if (code === 'r') {
56
+ style = undefined;
57
+ continue;
58
+ }
59
+ const next: CSSProperties = { ...(style ?? {}) };
60
+ const color = MC_FORMAT_COLORS[code];
61
+ if (color) {
62
+ next.color = color;
63
+ delete next.fontWeight;
64
+ delete next.fontStyle;
65
+ delete next.textDecoration;
66
+ } else if (code === 'l') next.fontWeight = 600;
67
+ else if (code === 'o') next.fontStyle = 'italic';
68
+ else if (code === 'n')
69
+ next.textDecoration = next.textDecoration
70
+ ? `${next.textDecoration} underline`
71
+ : 'underline';
72
+ else if (code === 'm')
73
+ next.textDecoration = next.textDecoration
74
+ ? `${next.textDecoration} line-through`
75
+ : 'line-through';
76
+ style = Object.keys(next).length ? next : undefined;
77
+ }
78
+ flush();
79
+ return (
80
+ <>
81
+ {runs.map((run, index) =>
82
+ run.style ? (
83
+ <span key={index} style={run.style}>
84
+ {run.text}
85
+ </span>
86
+ ) : (
87
+ <Fragment key={index}>{run.text}</Fragment>
88
+ ),
89
+ )}
90
+ </>
91
+ );
92
+ }
@@ -0,0 +1,14 @@
1
+ import { formatCount } from '@terrafirmagreg-team/ui/lib/utils/format';
2
+
3
+ export function StackCount({ count }: { count: number }) {
4
+ if (count < 1) return null;
5
+ return (
6
+ <span
7
+ aria-hidden
8
+ className="font-mc pointer-events-none leading-none text-white"
9
+ style={{ fontSize: 9, textShadow: '1px 1px 0 var(--color-mc-arrow)' }}
10
+ >
11
+ {formatCount(count)}
12
+ </span>
13
+ );
14
+ }
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import { useState, type ReactElement, type ReactNode } from 'react';
4
+ import { useAsyncValue } from '@terrafirmagreg-team/ui/hooks/use-async-value';
5
+ import { useModifierKeys } from '@terrafirmagreg-team/ui/hooks/use-modifier-keys';
6
+ import {
7
+ Tooltip,
8
+ TooltipContent,
9
+ TooltipTrigger,
10
+ } from '@terrafirmagreg-team/ui/components/ui/tooltip';
11
+ import { stackTooltip } from '@terrafirmagreg-team/game/lib/entry/tooltip';
12
+ import { formulaRuns } from '@terrafirmagreg-team/game/lib/entry/formula';
13
+ import { McText } from '@terrafirmagreg-team/game/components/entry/mc-text';
14
+ import { stripMcFormat } from '@terrafirmagreg-team/game/lib/dump/assets/lang/names';
15
+
16
+ const ENCHANTED_TEXT_COLOR = 'var(--color-mc-enchant)';
17
+
18
+ export type EntryTooltipLevel =
19
+ | 'compact'
20
+ | 'extended'
21
+ | 'full'
22
+ | 'adaptive'
23
+ | 'none';
24
+
25
+ function TooltipLine({ text }: { text: string }) {
26
+ const runs = formulaRuns(stripMcFormat(text));
27
+ if (!runs) return <McText text={text} />;
28
+ return (
29
+ <>
30
+ {runs.map((run, index) =>
31
+ run.color ? (
32
+ <span key={index} style={{ color: run.color }}>
33
+ {run.text}
34
+ </span>
35
+ ) : (
36
+ run.text
37
+ ),
38
+ )}
39
+ </>
40
+ );
41
+ }
42
+
43
+ export function EntryTooltip({
44
+ dataset,
45
+ id,
46
+ name,
47
+ kind,
48
+ lang,
49
+ langMap,
50
+ level = 'full',
51
+ footer,
52
+ children,
53
+ }: {
54
+ dataset: string;
55
+ id: string;
56
+ name: string;
57
+ kind?: 'item' | 'fluid' | 'entity';
58
+
59
+ lang?: string;
60
+ langMap?: Record<string, string>;
61
+
62
+ level?: EntryTooltipLevel;
63
+ footer?: ReactNode;
64
+ children: ReactElement;
65
+ }) {
66
+ const [open, setOpen] = useState(false);
67
+ const held = useModifierKeys(open && (level === 'full' || level === 'adaptive'));
68
+ const tooltip =
69
+ useAsyncValue(
70
+ open ? () => stackTooltip(dataset, id, lang, langMap, kind) : false,
71
+ [open, dataset, id, lang, langMap, kind],
72
+ ) ?? { lines: [], mods: {} };
73
+
74
+ const base = tooltip.lines;
75
+ const modLine =
76
+ base.length > 1 && !base[base.length - 1].includes('§')
77
+ ? base[base.length - 1]
78
+ : undefined;
79
+
80
+ let extra: string[];
81
+ if (level === 'compact') extra = modLine ? [modLine] : [];
82
+ else if (level === 'extended') extra = base;
83
+ else if (level === 'full') {
84
+ const activeMod = held.shift
85
+ ? 'shift'
86
+ : held.ctrl
87
+ ? 'ctrl'
88
+ : held.alt
89
+ ? 'alt'
90
+ : null;
91
+ extra = (activeMod ? tooltip.mods[activeMod] : undefined) ?? base;
92
+ } else if (held.ctrl || held.alt)
93
+ extra = (held.ctrl ? tooltip.mods.ctrl : tooltip.mods.alt) ?? base;
94
+ else if (held.shift) extra = base;
95
+ else extra = modLine ? [modLine] : [];
96
+
97
+ const lines = extra.length
98
+ ? [name, ...extra.filter((line) => line !== name)]
99
+ : [name];
100
+ const lastIsMod = lines.length > 1 && !lines[lines.length - 1].includes('§');
101
+
102
+ return (
103
+ <Tooltip onOpenChange={(next) => setOpen(next)}>
104
+ <TooltipTrigger render={children} delay={150} closeDelay={100} />
105
+ <TooltipContent className="flex-col items-start gap-0.5 p-3 text-sm break-words whitespace-pre-wrap">
106
+ {lines.map((line, index) => (
107
+ <p
108
+ key={`${index}:${line}`}
109
+ className={
110
+ index === 0
111
+ ? 'font-medium'
112
+ : lastIsMod && index === lines.length - 1
113
+ ? 'italic'
114
+ : 'text-muted-foreground'
115
+ }
116
+ style={
117
+ lastIsMod && index === lines.length - 1
118
+ ? { color: ENCHANTED_TEXT_COLOR }
119
+ : undefined
120
+ }
121
+ >
122
+ <TooltipLine text={line} />
123
+ </p>
124
+ ))}
125
+ {footer}
126
+ </TooltipContent>
127
+ </Tooltip>
128
+ );
129
+ }
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import type { ComponentProps } from 'react';
5
+ import { ItemEntry } from '@terrafirmagreg-team/game/components/entry/entry';
6
+ import { useEntryContext } from '@terrafirmagreg-team/game/hooks/use-entry-context';
7
+ import { useLangMap } from '@terrafirmagreg-team/game/hooks/use-lang-map';
8
+ import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
9
+ import { useAsyncValue } from '@terrafirmagreg-team/ui/hooks/use-async-value';
10
+ import { parseSectionLocation } from '@terrafirmagreg-team/kit/lib/location';
11
+ import { lookupSpriteInBundle } from '@terrafirmagreg-team/game/lib/dump/assets/atlas/lookup';
12
+ import { stackDisplayName } from '@terrafirmagreg-team/game/lib/dump/assets/lang/names';
13
+ import { loadClientModAtlases } from '@terrafirmagreg-team/game/lib/dump/client/atlases';
14
+ import { fetchTagItem } from '@terrafirmagreg-team/game/lib/entry/icon-load';
15
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
16
+
17
+ export function ItemIcon({
18
+ id,
19
+ dataset: datasetProp,
20
+ lang: langProp,
21
+ label,
22
+ children,
23
+ }: {
24
+ id?: string;
25
+ dataset?: string;
26
+ lang?: Lang;
27
+ label?: string;
28
+ children?: React.ReactNode;
29
+ } & ComponentProps<'div'>) {
30
+ const ctx = useEntryContext();
31
+ const pathname = usePathname();
32
+ const dataset =
33
+ datasetProp ?? ctx?.dataset ?? parseSectionLocation(pathname).dataset;
34
+ const resolvedLang = useResolvedLang();
35
+ const lang = langProp ?? ctx?.lang ?? resolvedLang;
36
+ const iconId = useAsyncValue(
37
+ dataset && id?.startsWith('#')
38
+ ? () => fetchTagItem(dataset, id, 'item').then((item) => item ?? id)
39
+ : false,
40
+ [dataset, id],
41
+ ) ?? id;
42
+ const langKey = useAsyncValue(
43
+ dataset && iconId?.includes(':') && !iconId.startsWith('#')
44
+ ? () =>
45
+ loadClientModAtlases(dataset, iconId.split(':', 2)[0]!).then(
46
+ (bundle) => lookupSpriteInBundle(bundle, iconId)?.sprite.langKey,
47
+ )
48
+ : false,
49
+ [dataset, iconId],
50
+ );
51
+ const fetchedMap = useLangMap(dataset, lang, !ctx?.langMap);
52
+ const map = ctx?.langMap ?? fetchedMap ?? {};
53
+ const resolved =
54
+ label ?? (iconId ? stackDisplayName(map, iconId, undefined, langKey) : undefined);
55
+ if (!iconId) return children ? <>{children}</> : null;
56
+ return (
57
+ <ItemEntry
58
+ id={iconId}
59
+ dataset={dataset}
60
+ lang={lang}
61
+ name={resolved ?? iconId}
62
+ labeled
63
+ label={resolved}
64
+ href={false}
65
+ >
66
+ {children}
67
+ </ItemEntry>
68
+ );
69
+ }