@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.
- package/package.json +58 -3
- package/src/components/entry/atlas-sprite.tsx +57 -0
- package/src/components/entry/copy-id-menu.tsx +55 -0
- package/src/components/entry/entry.tsx +229 -0
- package/src/components/entry/frame.tsx +32 -0
- package/src/components/entry/group.tsx +180 -0
- package/src/components/entry/icon/tabs.tsx +118 -0
- package/src/components/entry/icon.tsx +187 -0
- package/src/components/entry/list.tsx +70 -0
- package/src/components/entry/mc-text.tsx +92 -0
- package/src/components/entry/stack-count.tsx +14 -0
- package/src/components/entry/tooltip.tsx +129 -0
- package/src/components/mdx/item-icon.tsx +69 -0
- package/src/components/preview-icon.tsx +25 -0
- package/src/hooks/use-dump-location.ts +18 -0
- package/src/hooks/use-entry-context.ts +35 -0
- package/src/hooks/use-icon-source.ts +297 -0
- package/src/hooks/use-lang-map.ts +18 -0
- package/src/lib/dump/assets/atlas/dto.ts +49 -0
- package/src/lib/dump/assets/atlas/lookup.ts +206 -0
- package/src/lib/dump/assets/atlas/sprites.ts +130 -0
- package/src/lib/dump/assets/lang/load.ts +17 -0
- package/src/lib/dump/assets/lang/names.ts +244 -0
- package/src/lib/dump/assets/urls.ts +143 -0
- package/src/lib/dump/client/atlases.ts +83 -0
- package/src/lib/dump/client/dump.ts +188 -0
- package/src/lib/dump/client/groups.ts +31 -0
- package/src/lib/dump/client/keybinds.ts +11 -0
- package/src/lib/dump/client/mods.ts +16 -0
- package/src/lib/dump/client/registry.ts +125 -0
- package/src/lib/dump/client/tabs.ts +27 -0
- package/src/lib/dump/client/tags.ts +64 -0
- package/src/lib/dump/contract/kind.ts +61 -0
- package/src/lib/dump/contract/read.ts +77 -0
- package/src/lib/dump/contract/ref.ts +262 -0
- package/src/lib/dump/contract/text.ts +86 -0
- package/src/lib/dump/data/registry/dto.ts +56 -0
- package/src/lib/dump/data/registry/parse.ts +291 -0
- package/src/lib/dump/data/tab/dto.ts +13 -0
- package/src/lib/dump/data/tab/parse.ts +26 -0
- package/src/lib/dump/data/tag/dto.ts +7 -0
- package/src/lib/dump/data/tag/parse.ts +57 -0
- package/src/lib/dump/gen/data/guides/book.ts +56 -0
- package/src/lib/dump/gen/data/guides/category.ts +30 -0
- package/src/lib/dump/gen/data/guides/entry.ts +48 -0
- package/src/lib/dump/gen/data/models/model.ts +3 -0
- package/src/lib/dump/gen/data/quests/chapter.ts +126 -0
- package/src/lib/dump/gen/data/quests/chapter_groups.ts +11 -0
- package/src/lib/dump/gen/data/quests/data.ts +40 -0
- package/src/lib/dump/gen/data/recipes/recipe.ts +78 -0
- package/src/lib/dump/gen/index.ts +20 -0
- package/src/lib/dump/gen/keybinds.ts +3 -0
- package/src/lib/dump/gen/lang.ts +3 -0
- package/src/lib/dump/gen/manifest.ts +59 -0
- package/src/lib/dump/gen/registry/blocks.ts +60 -0
- package/src/lib/dump/gen/registry/entities.ts +49 -0
- package/src/lib/dump/gen/registry/fluids.ts +48 -0
- package/src/lib/dump/gen/registry/groups.ts +14 -0
- package/src/lib/dump/gen/registry/items.ts +51 -0
- package/src/lib/dump/gen/registry/recipe_categories.ts +64 -0
- package/src/lib/dump/gen/registry/recipes.ts +3 -0
- package/src/lib/dump/gen/registry/tabs.ts +46 -0
- package/src/lib/dump/gen/registry/tags.ts +11 -0
- package/src/lib/dump/json-reader.ts +231 -0
- package/src/lib/dump/manifest/dto.ts +11 -0
- package/src/lib/dump/manifest/parse.ts +167 -0
- package/src/lib/dump/mods/dto.ts +40 -0
- package/src/lib/dump/mods/index.ts +81 -0
- package/src/lib/dump/mods/load.ts +61 -0
- package/src/lib/dump/paths.ts +171 -0
- package/src/lib/dump/reader.ts +15 -0
- package/src/lib/entry/display.ts +4 -0
- package/src/lib/entry/entry.ts +57 -0
- package/src/lib/entry/formula.ts +226 -0
- package/src/lib/entry/href.ts +148 -0
- package/src/lib/entry/icon-load.ts +195 -0
- package/src/lib/entry/icon.ts +10 -0
- package/src/lib/entry/tooltip.ts +60 -0
- package/src/lib/preview.ts +18 -0
- package/src/lib/search.ts +31 -0
- package/src/lib/snbt.ts +246 -0
- package/src/locales/en_us.json +9 -0
- package/src/locales/ru_ru.json +9 -0
- package/src/locales.ts +5 -0
- package/src/types/entry.ts +52 -0
- package/styles/fonts.css +15 -0
- package/styles/tokens.css +15 -0
- package/styles.css +2 -0
- 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
|
+
}
|