diff --git a/public/images/crafting_table.png b/public/images/crafting_table.png new file mode 100644 index 00000000..1beddd8e Binary files /dev/null and b/public/images/crafting_table.png differ diff --git a/public/images/furnace.png b/public/images/furnace.png new file mode 100644 index 00000000..34a0a740 Binary files /dev/null and b/public/images/furnace.png differ diff --git a/public/images/smithing.png b/public/images/smithing.png new file mode 100644 index 00000000..a0812733 Binary files /dev/null and b/public/images/smithing.png differ diff --git a/public/images/stonecutter.png b/public/images/stonecutter.png new file mode 100644 index 00000000..f3063b0c Binary files /dev/null and b/public/images/stonecutter.png differ diff --git a/src/app/components/generator/PreviewPanel.tsx b/src/app/components/generator/PreviewPanel.tsx index b5e7da81..5cc5f8e7 100644 --- a/src/app/components/generator/PreviewPanel.tsx +++ b/src/app/components/generator/PreviewPanel.tsx @@ -4,9 +4,9 @@ import { useState } from 'preact/hooks' import { useModel } from '../../hooks/index.js' import type { VersionId } from '../../services/index.js' import { checkVersion } from '../../services/index.js' -import { BiomeSourcePreview, BlockStatePreview, DecoratorPreview, DensityFunctionPreview, LootTablePreview, ModelPreview, NoisePreview, NoiseSettingsPreview, StructureSetPreview } from '../previews/index.js' +import { BiomeSourcePreview, BlockStatePreview, DecoratorPreview, DensityFunctionPreview, LootTablePreview, ModelPreview, NoisePreview, NoiseSettingsPreview, RecipePreview, StructureSetPreview } from '../previews/index.js' -export const HasPreview = ['loot_table', 'dimension', 'worldgen/density_function', 'worldgen/noise', 'worldgen/noise_settings', 'worldgen/configured_feature', 'worldgen/placed_feature', 'worldgen/structure_set', 'block_definition', 'model'] +export const HasPreview = ['loot_table', 'recipe', 'dimension', 'worldgen/density_function', 'worldgen/noise', 'worldgen/noise_settings', 'worldgen/configured_feature', 'worldgen/placed_feature', 'worldgen/structure_set', 'block_definition', 'model'] type PreviewPanelProps = { model: DataModel | undefined, @@ -30,6 +30,10 @@ export function PreviewPanel({ model, version, id, shown }: PreviewPanelProps) { return } + if (id === 'recipe') { + return + } + if (id === 'dimension' && model.get(new Path(['generator', 'type']))?.endsWith('noise')) { return } diff --git a/src/app/components/previews/LootTablePreview.tsx b/src/app/components/previews/LootTablePreview.tsx index affe3f9c..af522469 100644 --- a/src/app/components/previews/LootTablePreview.tsx +++ b/src/app/components/previews/LootTablePreview.tsx @@ -1,11 +1,10 @@ import { DataModel } from '@mcschema/core' -import { useEffect, useRef, useState } from 'preact/hooks' +import { useMemo, useRef, useState } from 'preact/hooks' import { useLocale, useVersion } from '../../contexts/index.js' import { clamp, randomSeed } from '../../Utils.js' import { Btn, BtnMenu, NumberInput } from '../index.js' import { ItemDisplay } from '../ItemDisplay.jsx' import type { PreviewProps } from './index.js' -import type { SlottedItem } from './LootTable.js' import { generateLootTable } from './LootTable.js' export const LootTablePreview = ({ data }: PreviewProps) => { @@ -19,14 +18,10 @@ export const LootTablePreview = ({ data }: PreviewProps) => { const [advancedTooltips, setAdvancedTooltips] = useState(true) const overlay = useRef(null) - const [items, setItems] = useState([]) - const table = DataModel.unwrapLists(data) const state = JSON.stringify(table) - useEffect(() => { - const items = generateLootTable(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default' }) - console.log('Generated loot', items) - setItems(items) + const items = useMemo(() => { + return generateLootTable(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default' }) }, [version, seed, luck, daytime, weather, mixItems, state]) return <> diff --git a/src/app/components/previews/RecipePreview.tsx b/src/app/components/previews/RecipePreview.tsx new file mode 100644 index 00000000..6e0a5dcc --- /dev/null +++ b/src/app/components/previews/RecipePreview.tsx @@ -0,0 +1,211 @@ +import { DataModel } from '@mcschema/core' +import { Identifier, ItemStack } from 'deepslate' +import { useEffect, useMemo, useRef, useState } from 'preact/hooks' +import { useLocale, useVersion } from '../../contexts/index.js' +import { useAsync } from '../../hooks/useAsync.js' +import { fetchAllPresets } from '../../services/index.js' +import { Btn, BtnMenu } from '../index.js' +import { ItemDisplay } from '../ItemDisplay.jsx' +import type { PreviewProps } from './index.js' + +const ANIMATION_TIME = 1000 + +export const RecipePreview = ({ data }: PreviewProps) => { + const { locale } = useLocale() + const { version } = useVersion() + const [advancedTooltips, setAdvancedTooltips] = useState(true) + const [animation, setAnimation] = useState(0) + const overlay = useRef(null) + + const { value: itemTags } = useAsync(() => { + return fetchAllPresets(version, 'tag/item') + }, [version]) + + useEffect(() => { + const interval = setInterval(() => { + setAnimation(n => n + 1) + }, ANIMATION_TIME) + return () => clearInterval(interval) + }, []) + + const recipe = DataModel.unwrapLists(data) + const state = JSON.stringify(recipe) + const items = useMemo>(() => { + return placeItems(recipe, animation, itemTags ?? new Map()) + }, [state, animation, itemTags]) + + const gui = useMemo(() => { + const type = recipe.type?.replace(/^minecraft:/, '') + if (type === 'smelting' || type === 'blasting' || type === 'smoking' || type === 'campfire_cooking') { + return '/images/furnace.png' + } else if (type === 'stonecutting') { + return '/images/stonecutter.png' + } else if (type === 'smithing_transform' || type === 'smithing_trim') { + return '/images/smithing.png' + } else { + return '/images/crafting_table.png' + } + }, [state]) + + return <> + + + {[...items.entries()].map(([slot, item]) => + + + + )} + + + + {setAdvancedTooltips(!advancedTooltips); e.stopPropagation()}} /> + + + > +} + +const GUI_WIDTH = 176 +const GUI_HEIGHT = 81 +const SLOT_SIZE = 18 +const SLOTS = { + 'crafting.0': [29, 16], + 'crafting.1': [47, 16], + 'crafting.2': [65, 16], + 'crafting.3': [29, 34], + 'crafting.4': [47, 34], + 'crafting.5': [65, 34], + 'crafting.6': [29, 52], + 'crafting.7': [47, 52], + 'crafting.8': [65, 52], + 'crafting.result': [123, 34], + 'smelting.ingredient': [55, 16], + 'smelting.fuel': [55, 53], + 'smelting.result': [115, 34], + 'stonecutting.ingredient': [19, 32], + 'stonecutting.result': [142, 32], + 'smithing.template': [7, 47], + 'smithing.base': [25, 47], + 'smithing.addition': [43, 47], + 'smithing.result': [97, 47], +} +type Slot = keyof typeof SLOTS + +function slotStyle(slot: Slot) { + const [x, y] = SLOTS[slot] + return { + left: `${x*100/GUI_WIDTH}%`, + top: `${y*100/GUI_HEIGHT}%`, + width: `${SLOT_SIZE*100/GUI_WIDTH}%`, + height: `${SLOT_SIZE*100/GUI_HEIGHT}%`, + } +} + +function placeItems(recipe: any, animation: number, itemTags: Map) { + const items = new Map() + const type: string = recipe.type?.replace(/^minecraft:/, '') + if (type.startsWith('crafting_special') || type === 'crafting_decorated_pot') { + return items + } + + if (type === 'crafting_shapeless') { + const ingredients: any[] = Array.isArray(recipe.ingredients) ? recipe.ingredients : [] + ingredients.forEach((ingredient, i) => { + const choices = allIngredientChoices(ingredient, itemTags) + if (i >= 0 && i < 9 && choices.length > 0) { + const choice = choices[(3 * i + animation) % choices.length] + items.set(`crafting.${i}` as Slot, choice) + } + }) + } else if (type === 'crafting_shaped') { + const keys = new Map() + for (const [key, ingredient] of Object.entries(recipe.key ?? {})) { + const choices = allIngredientChoices(ingredient, itemTags) + console.log(choices) + if (choices.length > 0) { + const choice = choices[animation % choices.length] + keys.set(key, choice) + } + } + const pattern = recipe.pattern + for (let row = 0; row < Math.min(3, pattern.length); row += 1) { + for (let col = 0; col < Math.min(3, pattern[row].length); col += 1) { + const key = pattern[row].split('')[col] + const choice = key === ' ' ? undefined : keys.get(key) + if (choice) { + items.set(`crafting.${row * 3 + col}` as Slot, choice) + } + } + } + } else if (type === 'smelting' || type === 'smoking' || type === 'blasting' || type === 'campfire_cooking') { + const choices = allIngredientChoices(recipe.ingredient, itemTags) + if (choices.length > 0) { + const choice = choices[animation % choices.length] + items.set('smelting.ingredient' as Slot, choice) + } + } else if (type === 'stonecutting') { + const choices = allIngredientChoices(recipe.ingredient, itemTags) + if (choices.length > 0) { + const choice = choices[animation % choices.length] + items.set('stonecutting.ingredient' as Slot, choice) + } + } else if (type === 'smithing_transform' || type === 'smithing_trim') { + for (const ingredient of ['template', 'base', 'addition'] as const) { + const choices = allIngredientChoices(recipe[ingredient], itemTags) + if (choices.length > 0) { + const choice = choices[animation % choices.length] + items.set(`smithing.${ingredient}`, choice) + } + } + } + + let resultSlot: Slot = 'crafting.result' + if (type === 'smelting' || type === 'smoking' || type === 'blasting' || type === 'campfire_cooking') { + resultSlot = 'smelting.result' + } else if (type === 'stonecutting') { + resultSlot = 'stonecutting.result' + } else if (type === 'smithing_transform' || type === 'smithing_trim') { + resultSlot = 'smithing.result' + } + const result = recipe.result + if (type === 'smithing_trim') { + const base = items.get('smithing.base') + if (base) { + items.set(resultSlot, base) + } + } else if (typeof result === 'string') { + items.set(resultSlot, new ItemStack(Identifier.parse(result), 1)) + } else if (typeof result === 'object' && result !== null) { + const id = typeof result.id === 'string' ? result.id : 'minecraft:air' + const count = typeof result.count === 'number' ? result.count : 1 + // TODO: add components + items.set(resultSlot, new ItemStack(Identifier.parse(id), count)) + } + + return items +} + +function allIngredientChoices(ingredient: any, itemTags: Map): ItemStack[] { + if (Array.isArray(ingredient)) { + return ingredient.flatMap(i => allIngredientChoices(i, itemTags)) + } + if (typeof ingredient === 'object' && ingredient !== null) { + if (typeof ingredient.item === 'string') { + try { + return [new ItemStack(Identifier.parse(ingredient.item), 1)] + } catch (e) {} + } else if (typeof (ingredient.tag === 'string')) { + const tag: any = itemTags.get(ingredient.tag.replace(/^minecraft:/, '')) + if (typeof tag === 'object' && tag !== null && Array.isArray(tag.values)) { + return tag.values.flatMap((value: any) => { + if (typeof value !== 'string') return [] + if (value.startsWith('#')) return allIngredientChoices({ tag: value.slice(1) }, itemTags) + try { + return [new ItemStack(Identifier.parse(value), 1)] + } catch (e) {} + return [] + }) + } + } + } + return [] +} diff --git a/src/app/components/previews/index.ts b/src/app/components/previews/index.ts index e33b5cd4..abadf129 100644 --- a/src/app/components/previews/index.ts +++ b/src/app/components/previews/index.ts @@ -9,6 +9,7 @@ export * from './LootTablePreview.jsx' export * from './ModelPreview.jsx' export * from './NoisePreview.js' export * from './NoiseSettingsPreview.js' +export * from './RecipePreview.jsx' export * from './StructureSetPreview.jsx' export type PreviewProps = {