Files
misode.github.io/src/app/components/ItemTooltip.tsx
2024-11-27 21:46:47 +01:00

297 lines
14 KiB
TypeScript

import type { MobEffectInstance, NbtTag } from 'deepslate'
import { ItemStack, NbtCompound, NbtList, PotionContents } from 'deepslate'
import { Identifier } from 'deepslate/core'
import type { ResolvedItem } from '../services/ResolvedItem.js'
import { makeDescriptionId, mergeTextComponentStyles } from '../Utils.js'
import { TextComponent } from './TextComponent.jsx'
interface Props {
item: ResolvedItem,
advanced?: boolean,
resolver: (item: ItemStack) => ResolvedItem,
}
export function ItemTooltip({ item, advanced, resolver }: Props) {
if (item.has('hide_tooltip')) {
return <></>
}
return <>
<TextComponent component={item.getStyledHoverName()} />
{!advanced && !item.has('custom_name') && item.is('filled_map') && item.has('map_id') && (
<TextComponent component={{ translate: 'filled_map.id', with: [item.get('map_id', tag => tag.getAsNumber())], color: 'gray' }} />
)}
{!item.has('hide_additional_tooltip') && <>
{item.is('filled_map') && advanced && (item.get('map_id', tag => tag.isNumber())
? <TextComponent component={{ translate: 'filled_map.id', with: [item.get('map_id', tag => tag.getAsNumber())], color: 'gray' }} />
: <TextComponent component={{ translate: 'filled_map.unknown', color: 'gray' }} />
)}
{(item.id.path.endsWith('_banner') || item.is('shield')) && item.get('banner_patterns', tag => tag.isList() ? tag : [])?.map(layer =>
<TextComponent component={{ translate: `${layer.isCompound() ? (layer.hasCompound('pattern') ? layer.getString('translation_key') : `block.minecraft.banner.${layer.getString('pattern').replace(/^minecraft:/, '')}`) : ''}.${layer.isCompound() ? layer.getString('color') : ''}`, color: 'gray' }} />
)}
{item.is('crossbow') && item.getChargedProjectile() && (
<TextComponent component={{ translate: 'item.minecraft.crossbow.projectile', extra: [' ', resolver(item.getChargedProjectile()!).getDisplayName()] }}/>
)}
{item.is('disc_fragment_5') && (
<TextComponent component={{ translate: `${makeDescriptionId('item', item.id)}.desc`, color: 'gray' }} />
)}
{item.is('firework_rocket') && item.has('fireworks') && <>
{((item.get('fireworks', tag => tag.isCompound() ? tag.getNumber('flight_duration') : 0) ?? 0) > 0) && (
<TextComponent component={{ translate: 'item.minecraft.firework_rocket.flight', extra: [' ', item.get('fireworks', tag => tag.isCompound() ? tag.getNumber('flight_duration') : 0)], color: 'gray'}} />
)}
{/* TODO: firework explosions */}
</>}
{item.is('firework_star') && item.has('firework_explosion') && (
<TextComponent component={{ translate: `item.minecraft.firework_star.shape.${item.get('firework_explosion', tag => tag.isCompound() ? tag.getString('shape') : '')}`, color: 'gray' }} />
// TODO: additional stuff
)}
{/* TODO: painting variants */}
{item.is('goat_horn') && item.has('instrument') && (
<TextComponent component={mergeTextComponentStyles(item.get('instrument', tag => tag.isCompound()
? tag.get('description')?.toSimplifiedJson()
: { translate: makeDescriptionId('instrument', Identifier.parse(tag.getAsString()))}
), { color: 'gray' })} />
)}
{(item.is('lingering_potion') || item.is('potion') || item.is('splash_potion') || item.is('tipped_arrow')) && (
<PotionContentsTooltip contents={PotionContents.fromNbt(item.get('potion_contents', tag => tag) ?? NbtCompound.create())} factor={item.is('lingering_potion') ? 0.25 : item.is('tipped_arrow') ? 0.125 : 1} />
)}
{/* TODO: mob buckets */}
{/* TODO: smithing templates */}
{item.is('written_book') && item.has('written_book_content') && <>
<TextComponent component={{ translate: 'book.byAuthor', with: [item.get('written_book_content', tag => tag.isCompound() ? tag.getString('author') : undefined) ?? ''], color: 'gray' }} />
<TextComponent component={{ translate: `book.generation.${item.get('written_book_content', tag => tag.isCompound() ? tag.getNumber('generation') : undefined) ?? 0}`, color: 'gray' }} />
</>}
{(item.is('beehive') || item.is('bee_nest')) && <>
<TextComponent component={{ translate: 'container.beehive.bees', with: [item.get('bees', tag => tag.isList() ? tag.length : 0) ?? 0, 3], color: 'gray' }} />
<TextComponent component={{ translate: 'container.beehive.honey', with: [item.get('block_state', tag => tag.isCompound() ? tag.getString('honey_level') : 0) ?? 0, 5], color: 'gray' }} />
</>}
{item.is('decorated_pot') && item.has('pot_decorations') && <>
<TextComponent component={''} />
{item.get('pot_decorations', tag => tag.isList() ? tag.map(e =>
<TextComponent component={mergeTextComponentStyles(resolver(new ItemStack(Identifier.parse(e.getAsString()), 1)).getHoverName(), { color: 'gray' })} />
) : undefined)}
</>}
{item.id.path.endsWith('_shulker_box') && <>
{item.has('container_loot') && (
<TextComponent component={{ translate: 'container.shulkerBox.unknownContents' }} />
)}
{(item.get('container', tag => tag.isList() ? tag.getItems() : []) ?? []).slice(0, 5).map(e => {
const subItem = resolver(ItemStack.fromNbt(e.isCompound() ? e.getCompound('item') : new NbtCompound()))
return <TextComponent component={{ translate: 'container.shulkerBox.itemCount', with: [subItem.getHoverName(), subItem.count] }} />
})}
{(item.get('container', tag => tag.isList() ? tag.length : 0) ?? 0) > 5 && (
<TextComponent component={{ translate: 'container.shulkerBox.more', with: [(item.get('container', tag => tag.isList() ? tag.length : 0) ?? 0) - 5], italic: true }} />
)}
</>}
{/* TODO: spawner and trial spawner */}
</>}
{item.showInTooltip('jukebox_playable') && <>
<TextComponent component={mergeTextComponentStyles(item.get('jukebox_playable', tag => tag.isCompound() ? (
tag.hasCompound('song')
? tag.getCompound('song').get('description')?.toSimplifiedJson()
: { translate: makeDescriptionId('jukebox_song', Identifier.parse(tag.getString('song')))}
) : {}) ?? {}, { color: 'gray'})} />
</>}
{item.showInTooltip('trim') && <>
<TextComponent component={{ translate: makeDescriptionId('item', Identifier.create('smithing_template.upgrade' )), color: 'gray' }} />
<TextComponent component={{ text: ' ', extra: [item.get('trim', tag => tag.isCompound() ? (
tag.hasCompound('pattern')
? tag.getCompound('pattern').get('description')?.toSimplifiedJson()
: { translate: makeDescriptionId('trim_pattern', Identifier.parse(tag.getString('pattern'))), color: BUILTIN_TRIM_MATERIALS[tag.getString('material').replace(/^minecraft:/, '')] ?? 'gray' }
) : '')] }} />
<TextComponent component={{ text: ' ', extra: [item.get('trim', tag => tag.isCompound() ? (
tag.hasCompound('material')
? tag.getCompound('material').get('description')?.toSimplifiedJson()
: { translate: makeDescriptionId('trim_material', Identifier.parse(tag.getString('material'))), color: BUILTIN_TRIM_MATERIALS[tag.getString('material').replace(/^minecraft:/, '')] ?? 'gray' }
) : '')] }}/>
</>}
{item.showInTooltip('stored_enchantments') && (
<EnchantmentsTooltip data={item.get('stored_enchantments', tag => tag)} />
)}
{item.showInTooltip('enchantments') && (
<EnchantmentsTooltip data={item.get('enchantments', tag => tag)} />
)}
{item.showInTooltip('dyed_color') && (advanced
? <TextComponent component={{ translate: 'item.color', with: [`#${item.get('dyed_color', tag => tag.isCompound() ? tag.getNumber('rgb') : tag.getAsNumber())?.toString(16).padStart(6, '0')}`], color: 'gray' }} />
: <TextComponent component={{ translate: 'item.dyed', color: 'gray' }} />
)}
{item.getLore().map((component) =>
<TextComponent component={component} base={{ color: 'dark_purple', italic: true }} />
)}
{item.showInTooltip('attribute_modifiers') && (
<AttributeModifiersTooltip data={item.get('attribute_modifiers', tag => tag)} />
)}
{item.showInTooltip('unbreakable') && (
<TextComponent component={{ translate: 'item.unbreakable', color: 'blue' }} />
)}
{item.has('ominous_bottle_amplifier') && (
<PotionContentsTooltip contents={{ customEffects: [{ effect: Identifier.create('bad_omen'), amplifier: item.get('ominous_bottle_amplifier', tag => tag.getAsNumber()) ?? 0, duration: 120000 }]}} />
)}
{/* TODO: creative-only suspicious stew effects */}
{/* TODO: can break and can place on */}
{advanced && item.isDamageable() && (
<TextComponent component={{ translate: 'item.durability', with: [`${item.getMaxDamage() - item.getDamage()}`, `${item.getMaxDamage()}`] }} />
)}
{advanced && <>
<TextComponent component={{ text: item.id.toString(), color: 'dark_gray'}} />
{item.getSize() > 0 && <TextComponent component={{ translate: 'item.components', with: [item.getSize()], color: 'dark_gray' }} />}
</>}
</>
}
const BUILTIN_TRIM_MATERIALS: Record<string, string | undefined> = {
amethyst: '#9A5CC6',
copper: '#B4684D',
diamond: '#6EECD2',
emerald: '#11A036',
gold: '#DEB12D',
iron: '#ECECEC',
lapis: '#416E97',
netherite: '#625859',
quartz: '#E3D4C4',
redstone: '#971607',
}
const HARMFUL_EFFECTS = new Set([
'minecraft:slowness',
'minecraft:mining_fatigue',
'minecraft:instant_damage',
'minecraft:nausea',
'minecraft:blindness',
'minecraft:hunger',
'minecraft:weakness',
'minecraft:poison',
'minecraft:wither',
'minecraft:levitation',
'minecraft:unluck',
'minecraft:darkness',
'minecraft:wind_charged',
'minecraft:weaving',
'minecraft:oozing',
'minecraft:infested',
])
function PotionContentsTooltip({ contents, factor }: { contents: PotionContents, factor?: number }) {
const effects = PotionContents.getAllEffects(contents)
return <>
{effects.map(e => {
const color = HARMFUL_EFFECTS.has(e.effect.toString()) ? 'red' : 'blue'
let component: any = { translate: makeDescriptionId('effect', e.effect) }
if (e.amplifier > 0) {
component = { translate: 'potion.withAmplifier', with: [component, { translate: `potion.potency.${e.amplifier}` }] }
}
if (e.duration === -1 || e.duration > 20) {
component = { translate: 'potion.withDuration', with: [component, formatDuration(e, factor ?? 1)] }
}
return <TextComponent component={{ ...component, color }} />
})}
{effects.length === 0 && <TextComponent component={{ translate: 'effect.none', color: 'gray' }} />}
</>
}
function formatDuration(effect: MobEffectInstance, factor: number) {
if (effect.duration === -1) {
return { translate: 'effect.duration.infinite' }
}
const ticks = Math.floor(effect.duration * factor)
let seconds = Math.floor(ticks / 20)
let minutes = Math.floor(seconds / 60)
seconds %= 60
const hours = Math.floor(minutes / 60)
minutes %= 60
return `${hours > 0 ? `${hours}:` : ''}${minutes.toFixed().padStart(2, '0')}:${seconds.toFixed().padStart(2, '0')}`
}
function EnchantmentsTooltip({ data }: { data: NbtTag | undefined }) {
if (!data || !data.isCompound()) {
return <></>
}
const levels = data.hasCompound('levels') ? data.getCompound('levels') : data
return <>
{[...levels.keys()].map((key) => {
const level = levels.getNumber(key)
if (level <= 0) return <></>
const id = Identifier.parse(key)
return <TextComponent component={{ translate: makeDescriptionId('enchantment', id), color: id.path.endsWith('_curse') ? 'red' : 'gray', extra: level === 1 ? [] : [' ', { translate: `enchantment.level.${level}` }] }} />
})}
</>
}
const EQUIPMENT_GROUPS = [
'any',
'mainhand',
'offhand',
'hand',
'feet',
'legs',
'chest',
'head',
'armor',
'body',
]
const MODIFIER_OPERATIONS = [
'add_value',
'add_multiplied_base',
'add_multiplied_total',
]
const NEGATIVE_ATTRIBUTES = new Set([
'minecraft:burning_time',
'minecraft:fall_damage_multiplier',
])
const NEUTRAL_ATTRIBUTES = new Set([
'minecraft:gravity',
'minecraft:scale',
])
function AttributeModifiersTooltip({ data }: { data: NbtTag | undefined }) {
const modifiers = data?.isList() ? data : data?.isCompound() ? data.getList('modifiers') : new NbtList()
return <>
{EQUIPMENT_GROUPS.map(group => {
let first = true
return modifiers.map((e) => {
if (!e.isCompound()) return
const slot = e.has('slot') ? e.getString('slot') : 'any'
if (slot !== group) return
const wasFirst = first
first = false
let amount = e.getNumber('amount')
const type = Identifier.parse(e.getString('type'))
const id = Identifier.parse(e.getString('id'))
const operation = MODIFIER_OPERATIONS.indexOf(e.getString('operation'))
let absolute = false
if (id.equals(Identifier.create('base_attack_damage'))) {
amount += 1
absolute = true
} else if (id.equals(Identifier.create('base_attack_speed'))) {
amount += 4
absolute = true
}
if (operation !== 0) {
amount *= 100
} else if (type.equals(Identifier.create('knockback_resistance'))) {
amount *= 10
}
return <>
{wasFirst && <>
<TextComponent component={''} />
<TextComponent component={{ translate: `item.modifiers.${group}`, color: 'gray' }} />
</>}
{absolute ? (
<TextComponent component={[' ', { translate: `attribute.modifier.equals.${operation}`, with: [+amount.toFixed(2), { translate: `attribute.name.${type.path}`}], color: 'dark_green' }]} />
) : amount > 0 ? (
<TextComponent component={{ translate: `attribute.modifier.plus.${operation}`, with: [+amount.toFixed(2), { translate: `attribute.name.${type.path}`}], color: NEGATIVE_ATTRIBUTES.has(type.toString()) ? 'red' : NEUTRAL_ATTRIBUTES.has(type.toString()) ? 'gray' : 'blue' }} />
) : amount < 0 ? (
<TextComponent component={{ translate: `attribute.modifier.take.${operation}`, with: [+(-amount).toFixed(2), { translate: `attribute.name.${type.path}`}], color: NEGATIVE_ATTRIBUTES.has(type.toString()) ? 'blue' : NEUTRAL_ATTRIBUTES.has(type.toString()) ? 'gray' : 'red'}} />
) : <></>}
</>
})
})}
</>
}