import React, { useState, useEffect } from 'react' import { useInventoryContext } from '../../context/InventoryContext' import { useTextures } from '../../context/TextureContext' import { useScale } from '../../context/ScaleContext' // Bundled payment item textures (not available via texture provider in the full client) import netherite_ingot_png from '../../assets/beacon/netherite_ingot.png' import emerald_png from '../../assets/beacon/emerald.png' import diamond_png from '../../assets/beacon/diamond.png' import gold_ingot_png from '../../assets/beacon/gold_ingot.png' import iron_ingot_png from '../../assets/beacon/iron_ingot.png' const SPRITE_BASE = '1.21.11/textures/gui/sprites/container/beacon' const MOB_EFFECT_BASE = '1.21.11/textures/mob_effect' // Effect IDs matching vanilla BeaconMenu.encodeEffect: registry_id + 1 // ContainerData: ≤0 = none, 1+ = effect (1-based) // Protocol set_beacon_effect option(varint) uses the same 1-based encoding const EFFECTS = { NONE: -1, SPEED: 1, // registry 0 + 1 HASTE: 3, // registry 2 + 1 STRENGTH: 5, // registry 4 + 1 JUMP_BOOST: 8, // registry 7 + 1 REGENERATION: 10, // registry 9 + 1 RESISTANCE: 11, // registry 10 + 1 } as const const EFFECT_INFO: Record = { [EFFECTS.SPEED]: { name: 'Speed', sprite: 'speed' }, [EFFECTS.HASTE]: { name: 'Haste', sprite: 'haste' }, [EFFECTS.RESISTANCE]: { name: 'Resistance', sprite: 'resistance' }, [EFFECTS.JUMP_BOOST]: { name: 'Jump Boost', sprite: 'jump_boost' }, [EFFECTS.STRENGTH]: { name: 'Strength', sprite: 'strength' }, [EFFECTS.REGENERATION]: { name: 'Regeneration', sprite: 'regeneration' }, } // Primary buttons: 3 tiers of effects const PRIMARY_TIERS: { tier: number; y: number; effects: number[] }[] = [ { tier: 0, y: 22, effects: [EFFECTS.SPEED, EFFECTS.HASTE] }, { tier: 1, y: 47, effects: [EFFECTS.RESISTANCE, EFFECTS.JUMP_BOOST] }, { tier: 2, y: 72, effects: [EFFECTS.STRENGTH] }, ] const PRIMARY_CENTER_X = 76 // Payment indicator items shown next to the payment slot (vanilla renderBg) const PAYMENT_ITEMS: { src: string; x: number }[] = [ { src: netherite_ingot_png, x: 20 }, { src: emerald_png, x: 41 }, { src: diamond_png, x: 63 }, { src: gold_ingot_png, x: 86 }, { src: iron_ingot_png, x: 108 }, ] interface BeaconEffectsProps { properties: Record } export function BeaconEffects({ properties }: BeaconEffectsProps) { const { sendAction, getSlot } = useInventoryContext() const textures = useTextures() const { scale } = useScale() const levels = properties.levels ?? 0 // ContainerData: ≤0 means "no effect" (server sends -1 for uninitialized, vanilla sends 0) const serverPrimary = (properties.primaryEffect ?? -1) > 0 ? properties.primaryEffect! : EFFECTS.NONE const serverSecondary = (properties.secondaryEffect ?? -1) > 0 ? properties.secondaryEffect! : EFFECTS.NONE const [selectedPrimary, setSelectedPrimary] = useState(serverPrimary) const [selectedSecondary, setSelectedSecondary] = useState(serverSecondary) const [hoveredButton, setHoveredButton] = useState(null) // Sync local state when server properties change useEffect(() => { setSelectedPrimary(serverPrimary) setSelectedSecondary(serverSecondary) }, [serverPrimary, serverSecondary]) // Payment slot is index 0 in the beacon container const paymentSlot = getSlot(0) const hasPayment = paymentSlot?.item != null // Confirm requires payment AND a primary selection (valid effects are > 0) const canConfirm = hasPayment && selectedPrimary > 0 // Upgrade button mirrors the LOCAL selected primary (vanilla: BeaconScreen.this.primary) const showUpgrade = selectedPrimary > 0 const getButtonSprite = (disabled: boolean, selected: boolean, hovered: boolean) => { if (disabled) return textures.getGuiTextureUrl(`${SPRITE_BASE}/button_disabled.png`) if (selected) return textures.getGuiTextureUrl(`${SPRITE_BASE}/button_selected.png`) if (hovered) return textures.getGuiTextureUrl(`${SPRITE_BASE}/button_highlighted.png`) return textures.getGuiTextureUrl(`${SPRITE_BASE}/button.png`) } const getEffectSprite = (effect: number) => { const info = EFFECT_INFO[effect] if (!info) return undefined return textures.getGuiTextureUrl(`${MOB_EFFECT_BASE}/${info.sprite}.png`) } const handleConfirm = () => { if (!canConfirm) return sendAction({ type: 'beacon', primaryEffect: selectedPrimary, secondaryEffect: selectedSecondary, }) sendAction({ type: 'close' }) } const handleCancel = () => { sendAction({ type: 'close' }) } // Center buttons around a given X coordinate const getButtonX = (centerX: number, index: number, count: number) => { const totalWidth = count * 22 + (count - 1) * 2 return centerX + index * 24 - totalWidth / 2 } const renderEffectButton = ( effect: number, x: number, y: number, disabled: boolean, isSelected: boolean, isPrimary: boolean, key: string, tooltip?: string, ) => { const hovered = hoveredButton === key && !disabled const info = EFFECT_INFO[effect] const title = tooltip ?? info?.name return (
{ if (disabled) return if (isPrimary) { setSelectedPrimary(effect) } else { setSelectedSecondary(effect) } }} onMouseEnter={() => setHoveredButton(key)} onMouseLeave={() => setHoveredButton(null)} title={title} style={{ position: 'absolute', left: x * scale, top: y * scale, width: 22 * scale, height: 22 * scale, cursor: disabled ? 'default' : 'pointer', }} > {getEffectSprite(effect) && ( )}
) } const labelStyle = (x: number): React.CSSProperties => ({ position: 'absolute', left: x * scale, top: 10 * scale, fontSize: 8 * scale, fontFamily: "'Minecraft', monospace", color: '#E0E0E0', textShadow: 'none', transform: 'translateX(-50%)', whiteSpace: 'nowrap', pointerEvents: 'none', }) return ( <> {/* Labels */} Primary Power Secondary Power {/* Payment item indicators (decorative icons showing valid payment items) */} {PAYMENT_ITEMS.map(({ src, x }) => ( ))} {/* Primary effect buttons (3 tiers) */} {PRIMARY_TIERS.map(({ tier, y, effects }) => effects.map((effect, idx) => { const x = getButtonX(PRIMARY_CENTER_X, idx, effects.length) const disabled = tier >= levels const isSelected = selectedPrimary === effect return renderEffectButton(effect, x, y, disabled, isSelected, true, `primary-${effect}`) }) )} {/* Secondary: Regeneration button (fixed position x=144) */} {renderEffectButton( EFFECTS.REGENERATION, 144, 47, 3 >= levels, selectedSecondary === EFFECTS.REGENERATION, false, 'secondary-regen', )} {/* Secondary: Upgrade button (fixed position x=168, mirrors local primary selection) */} {showUpgrade && renderEffectButton( selectedPrimary, 168, 47, 3 >= levels, selectedSecondary === selectedPrimary, false, 'secondary-upgrade', `${EFFECT_INFO[selectedPrimary]?.name ?? 'Effect'} II`, )} {/* Confirm button */}
setHoveredButton('confirm')} onMouseLeave={() => setHoveredButton(null)} title="Done" style={{ position: 'absolute', left: 164 * scale, top: 107 * scale, width: 22 * scale, height: 22 * scale, cursor: canConfirm ? 'pointer' : 'default', }} >
{/* Cancel button */}
setHoveredButton('cancel')} onMouseLeave={() => setHoveredButton(null)} title="Cancel" style={{ position: 'absolute', left: 190 * scale, top: 107 * scale, width: 22 * scale, height: 22 * scale, cursor: 'pointer', }} >
) }