import {useCallback, useEffect, useRef, useState} from 'react'; import {useClient, usePacket} from '@/react/context/client.js'; import {useDebug} from '@/react/context/debug.js'; import {useEcs, useEcsTick} from '@/react/context/ecs.js'; import {useMainEntity} from '@/react/context/main-entity.js'; import {RESOLUTION} from '@/lib/constants.js'; import EventEmitter from '@/lib/event-emitter.js'; import {distribute} from '@/lib/inventory.js'; import addKeyListener from './add-key-listener.js'; import Disconnected from './dom/disconnected.jsx'; import Chat from './dom/chat/chat.jsx'; import Bag from './dom/bag.jsx'; import DateTime from './dom/datetime.jsx'; import Dom from './dom/dom.jsx'; import Entities from './dom/entities.jsx'; import External from './dom/external.jsx'; import Trade from './dom/trade.jsx'; import Wallet from './dom/wallet.jsx'; import HotBar from './dom/hotbar.jsx'; import Pixi from './pixi/pixi.jsx'; import Devtools from './devtools.jsx'; import styles from './ui.module.css'; const KEY_MAP = { keyDown: 1, keyUp: 0, }; function emptySlots() { return Array(10).fill(undefined); } const devEventsChannel = new EventEmitter(); function Ui({disconnected}) { // Key input. const client = useClient(); const chatInputRef = useRef(); const gameRef = useRef(); const pixiRef = useRef(); const mainEntityRef = useMainEntity(); const [, setDebug] = useDebug(); const ecsRef = useEcs(); const [showDisconnected, setShowDisconnected] = useState(false); const [bufferSlot, setBufferSlot] = useState(); const hadBufferSlot = useRef(); const [distributing, setDistributing] = useState({}); const [devtoolsIsOpen, setDevtoolsIsOpen] = useState(false); const ratio = (RESOLUTION.x * (devtoolsIsOpen ? 2 : 1)) / RESOLUTION.y; const [camera, setCamera] = useState({x: 0, y: 0}); const [hotbarSlots, setHotbarSlots] = useState(emptySlots()); const [inventorySlots, setInventorySlots] = useState(emptySlots()); const [activeSlot, setActiveSlot] = useState(0); const [scale, setScale] = useState(2); const monopolizers = useRef([]); const [message, setMessage] = useState(''); const [chatIsOpen, setChatIsOpen] = useState(false); const [chatHistory, setChatHistory] = useState([]); const [chatHistoryCaret, setChatHistoryCaret] = useState(-1); const [chatMessages, setChatMessages] = useState({}); const [pendingMessage, setPendingMessage] = useState(''); const [hotbarIsHidden, setHotbarIsHidden] = useState(true); const hotbarHideHandle = useRef(); const [isInventoryOpen, setIsInventoryOpen] = useState(false); const [externalInventory, setExternalInventory] = useState(); const [externalInventorySlots, setExternalInventorySlots] = useState(); const [gaining, setGaining] = useState([]); const [losing, setLosing] = useState([]); const [wallet, setWallet] = useState(0); const [particleWorker, setParticleWorker] = useState(); const [trading, setTrading] = useState(false); useEffect(() => { let handle; if (disconnected) { handle = setTimeout(() => { setShowDisconnected(true); }, 1000); } else { setShowDisconnected(false) } return () => { clearTimeout(handle); }; }, [disconnected]); const keepHotbarOpen = useCallback(() => { if (!isInventoryOpen) { setHotbarIsHidden(false); if (hotbarHideHandle.current) { clearTimeout(hotbarHideHandle.current); } hotbarHideHandle.current = setTimeout(() => { setHotbarIsHidden(true); }, 4000); } }, [isInventoryOpen]); useEffect(() => { return addKeyListener(document.body, ({event, payload, type}) => { const actionMap = { 'd': {type: 'moveRight'}, 's': {type: 'moveDown'}, 'a': {type: 'moveLeft'}, 'w': {type: 'moveUp'}, 'e': {type: 'interact'}, '1': {type: 'changeSlot', payload: 1}, '2': {type: 'changeSlot', payload: 2}, '3': {type: 'changeSlot', payload: 3}, '4': {type: 'changeSlot', payload: 4}, '5': {type: 'changeSlot', payload: 5}, '6': {type: 'changeSlot', payload: 6}, '7': {type: 'changeSlot', payload: 7}, '8': {type: 'changeSlot', payload: 8}, '9': {type: 'changeSlot', payload: 9}, '0': {type: 'changeSlot', payload: 10}, '`': {type: 'openInventory'}, '-': {type: 'zoomOut'}, '+': {type: 'zoomIn'}, '=': {type: 'zoomIn'}, 'F3': {type: 'debug'}, 'F4': {type: 'devtools'}, 'Enter': {type: 'openChat'}, 'Escape': {type: 'closeChat'}, }; const action = actionMap[payload]; if (!action) { return; } if (chatInputRef.current) { if ('closeChat' === action.type && 'keyDown' === type) { setChatIsOpen(false); return; } chatInputRef.current.focus(); return; } let actionPayload; switch (action.type) { case 'interact': { if ('keyDown' === type) { if (monopolizers.current.length > 0) { monopolizers.current[0].trigger(); break; } } actionPayload = {type: 'interact', value: KEY_MAP[type]}; break; } case 'moveRight': case 'moveDown': case 'moveLeft': case 'moveUp': { actionPayload = {type: action.type, value: 'keyDown' === type ? 1 : 0}; break; } case 'changeSlot': { if ('keyDown' === type) { keepHotbarOpen(); actionPayload = {type: 'changeSlot', value: action.payload}; } break; } case 'openInventory': { if (event) { event.preventDefault(); } if ('keyDown' === type) { setIsInventoryOpen((isInventoryOpen) => { if (isInventoryOpen) { setHotbarIsHidden(true); } else { setHotbarIsHidden(false); if (hotbarHideHandle.current) { clearTimeout(hotbarHideHandle.current); } } return !isInventoryOpen; }); } return; } case 'zoomIn': { if ('keyDown' === type) { setScale((scale) => scale < 4 ? Math.floor(scale + 1) : 4); } return; } case 'zoomOut': { if ('keyDown' === type) { setScale((scale) => scale > 1 ? scale - 1 : 1); } return; } case 'debug': { if (event) { event.preventDefault(); } if ('keyDown' === type) { setDebug(({...debug}) => ({...debug, info: !debug.info})); } return; } case 'devtools': { if (event) { event.preventDefault(); } if ('keyDown' === type) { setDevtoolsIsOpen((devtoolsIsOpen) => !devtoolsIsOpen); } return; } case 'openChat': { if ('keyDown' === type) { setChatIsOpen(true); } return; } } if (actionPayload) { client.send({ type: 'Action', payload: actionPayload, }); } }); }, [client, keepHotbarOpen, mainEntityRef, setDebug]); const onEcsChangePacket = useCallback(() => { mainEntityRef.current = undefined; monopolizers.current = []; }, [ mainEntityRef, ]); usePacket('EcsChange', onEcsChangePacket); const onEcsTick = useCallback((payload, ecs) => { for (const id in payload) { const entity = ecs.get(id); const update = payload[id]; if (!update) { continue; } if (update.MainEntity) { mainEntityRef.current = id; } if (update.Wallet && mainEntityRef.current === id) { setWallet(update.Wallet.gold); } if (update.Inventory) { if (mainEntityRef.current === id) { setBufferSlot(entity.Inventory.item(0)); setHotbarSlots(() => { const newHotbarSlots = []; for (let i = 1; i < 11; ++i) { const item = entity.Inventory.item(i); newHotbarSlots.push( item ? { icon: item.icon, label: item.label, price: item.price, qty: item.qty, } : undefined, ); } return newHotbarSlots; }); const newInventorySlots = emptySlots(); for (let i = 11; i < 41; ++i) { newInventorySlots[i - 11] = entity.Inventory.item(i); } setInventorySlots(newInventorySlots); } else if (update.Inventory.slots) { const newInventorySlots = Array(30).fill(undefined); for (let i = 0; i < 30; ++i) { newInventorySlots[i] = entity.Inventory.item(i); } setExternalInventory(entity.id) setTrading(!!entity.Shop); setExternalInventorySlots(newInventorySlots); setIsInventoryOpen(true); setHotbarIsHidden(false); if (hotbarHideHandle.current) { clearTimeout(hotbarHideHandle.current); } } else if (update.Inventory.closed) { setExternalInventory(); setExternalInventorySlots(); setGaining([]); setLosing([]); setTrading(false); } } if (mainEntityRef.current === id) { if (update.Wielder && 'activeSlot' in update.Wielder) { setActiveSlot(update.Wielder.activeSlot); } } } }, [mainEntityRef]); useEcsTick(onEcsTick); const onEcsTickParticles = useCallback((payload, ecs) => { if (!payload[1]?.AreaSize) { return; } if (particleWorker) { particleWorker.terminate(); } const localParticleWorker = new Worker( new URL('./particle-worker.js', import.meta.url), {type: 'module'}, ); localParticleWorker.addEventListener('message', () => { localParticleWorker.postMessage(ecs.get(1).toJSON()); setParticleWorker(localParticleWorker); }); }, [particleWorker]); useEcsTick(onEcsTickParticles); const onEcsTickSound = useCallback((payload) => { for (const id in payload) { const update = payload[id]; if (update.Sound?.play) { for (const sound of update.Sound.play) { (new Audio(sound)).play(); } } } }, []); useEcsTick(onEcsTickSound); const onEcsTickAabbs = useCallback((payload, ecs) => { for (const id in payload) { const entity = ecs.get(id); const update = payload[id]; if (!update) { continue; } if (update.Direction && entity.Collider) { entity.Collider.updateAabbs(); } } }, []); useEcsTick(onEcsTickAabbs); const onEcsTickCamera = useCallback((payload) => { if (mainEntityRef.current && payload[mainEntityRef.current]?.Camera) { setCamera((camera) => ({ x: camera.x, y: camera.y, ...payload[mainEntityRef.current].Camera, })) } }, [mainEntityRef]); useEcsTick(onEcsTickCamera); useEffect(() => { function onContextMenu(event) { event.preventDefault(); } document.body.addEventListener('contextmenu', onContextMenu); return () => { document.body.removeEventListener('contextmenu', onContextMenu); }; }, []); const computePosition = useCallback(({clientX, clientY}) => { if (!gameRef.current || !mainEntityRef.current || !ecsRef.current) { return; } const {top, left, width} = gameRef.current.getBoundingClientRect(); const master = ecsRef.current.get(1); if (!master) { return; } const {Camera} = ecsRef.current.get(mainEntityRef.current); const size = width / RESOLUTION.x; const camera = { x: ((Camera.x * scale) - (RESOLUTION.x / 2)), y: ((Camera.y * scale) - (RESOLUTION.y / 2)), } return { x: (((clientX - left) / size) + camera.x) / scale, y: (((clientY - top) / size) + camera.y) / scale, }; }, [ ecsRef, mainEntityRef, scale, ]); const hotbarOnSlotMouseDown = useCallback((i, event) => { keepHotbarOpen(); if (trading) { const index = losing.indexOf(i + 1); if (-1 === index) { losing.push(i + 1); } else { losing.splice(index, 1); } setLosing([...losing]); } else { hadBufferSlot.current = bufferSlot; switch (event.button) { case 0: if (bufferSlot) { // ... } else { client.send({ type: 'Action', payload: {type: 'swapSlots', value: [0, mainEntityRef.current, i + 1]}, }); } break; case 2: client.send({ type: 'Action', payload: { type: 'distribute', value: [ i + 1, [ [mainEntityRef.current, 0], [mainEntityRef.current, i + 1], ], ], }, }); break; } } }, [bufferSlot, client, keepHotbarOpen, losing, mainEntityRef, trading]); const hotbarOnSlotMouseMove = useCallback((i) => { if (hadBufferSlot.current) { setDistributing((distributing) => ({ ...distributing, [[mainEntityRef.current, i + 1].join(':')]: true, })); } }, [mainEntityRef]); useEffect(() => { if (!bufferSlot) { return; } const entity = ecsRef.current.get(mainEntityRef.current); setHotbarSlots(() => { const newHotbarSlots = []; for (let i = 1; i < 11; ++i) { const item = entity.Inventory.item(i); newHotbarSlots.push( item ? {icon: item.icon, qty: item.qty} : undefined, ); } const qtys = []; for (const key in distributing) { const [entityId, slot] = key.split(':'); const {Inventory} = ecsRef.current.get(entityId); qtys.push(Inventory.slots[slot]?.qty ?? 0); } const item = { maximumStack: bufferSlot.maximumStack, qty: bufferSlot.qty, }; const distributed = distribute(item, qtys); let i = 0; for (const key in distributing) { const [entityId, slot] = key.split(':'); if ( entityId == mainEntityRef.current && (slot >= 1 && slot <= 11) ) { if (!newHotbarSlots[slot - 1]) { newHotbarSlots[slot - 1] = { icon: bufferSlot.icon, }; } newHotbarSlots[slot - 1].qty = distributed[i]; newHotbarSlots[slot - 1].temporary = true; } i += 1; } return newHotbarSlots; }); }, [bufferSlot, distributing, ecsRef, mainEntityRef]) const hotbarOnSlotMouseUp = useCallback((i, event) => { keepHotbarOpen(); if (trading) { // ... } else { switch (event.button) { case 0: if (Object.keys(distributing).length > 0) { client.send({ type: 'Action', payload: { type: 'distribute', value: [ 0, Object.keys(distributing).map((idAndSlot) => idAndSlot.split(':')), ], }, }); setDistributing({}); } else if (hadBufferSlot.current) { client.send({ type: 'Action', payload: { type: 'distribute', value: [ 0, [ [mainEntityRef.current, i + 1], ], ], }, }); } else { // ... } break; } } hadBufferSlot.current = undefined; }, [client, distributing, keepHotbarOpen, mainEntityRef, trading]); const bagOnSlotMouseDown = useCallback((i) => { if (trading) { const index = losing.indexOf(i + 11); if (-1 === index) { losing.push(i + 11); } else { losing.splice(index, 1); } setLosing([...losing]); } else { client.send({ type: 'Action', payload: {type: 'swapSlots', value: [0, mainEntityRef.current, i + 11]}, }); } }, [client, losing, mainEntityRef, trading]); const externalInventoryOnSlotMouseDown = useCallback((i) => { if (trading) { const index = gaining.indexOf(i); if (-1 === index) { gaining.push(i); } else { gaining.splice(index, 1); } setGaining([...gaining]); } else { client.send({ type: 'Action', payload: {type: 'swapSlots', value: [0, externalInventory, i]}, }); } }, [client, externalInventory, gaining, trading]); const onTradeAccepted = useCallback(() => { client.send({ type: 'Action', payload: {type: 'acceptTrade', value: {gaining, losing}}, }); setGaining([]); setLosing([]); }, [client, gaining, losing]); useEffect(() => { if (!pixiRef.current) { return; } pixiRef.current.scale.set(scale); pixiRef.current.x = -Math.round((camera.x * scale) - RESOLUTION.x / 2); pixiRef.current.y = -Math.round((camera.y * scale) - RESOLUTION.y / 2); }, [camera, scale]) return (