silphius/app/react/components/ui.jsx
2024-09-12 16:24:26 -05:00

577 lines
18 KiB
JavaScript

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 '@/util/constants.js';
import EventEmitter from '@/util/event-emitter.js';
import addKeyListener from './add-key-listener.js';
import ClientEcs from './client-ecs.js';
import Disconnected from './dom/disconnected.jsx';
import Chat from './dom/chat/chat.jsx';
import Bag from './dom/bag.jsx';
import Dom from './dom/dom.jsx';
import Entities from './dom/entities.jsx';
import External from './dom/external.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 latestTick = useRef();
const gameRef = useRef();
const mainEntityRef = useMainEntity();
const [, setDebug] = useDebug();
const ecsRef = useEcs();
const [showDisconnected, setShowDisconnected] = useState(false);
const [bufferSlot, setBufferSlot] = 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 [Components, setComponents] = useState();
const [Systems, setSystems] = useState();
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 [particleWorker, setParticleWorker] = useState();
const refreshEcs = useCallback(() => {
class ClientEcsPerf extends ClientEcs {
markChange() {}
}
ecsRef.current = new ClientEcsPerf({Components, Systems});
}, [ecsRef, Components, Systems]);
useEffect(() => {
async function setEcsStuff() {
const {default: Components} = await import('@/ecs/components/index.js');
const {default: Systems} = await import('@/ecs/systems/index.js');
setComponents(Components);
setSystems(Systems);
}
setEcsStuff();
}, []);
useEffect(() => {
refreshEcs();
}, [refreshEcs]);
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);
}
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, setDebug]);
const onEcsChangePacket = useCallback(() => {
refreshEcs();
mainEntityRef.current = undefined;
monopolizers.current = [];
}, [refreshEcs, mainEntityRef]);
usePacket('EcsChange', onEcsChangePacket);
const onTickPacket = useCallback(async (payload, client) => {
if (0 === Object.keys(payload.ecs).length) {
return;
}
latestTick.current = Promise.resolve(latestTick.current).then(async () => {
await ecsRef.current.apply(payload.ecs);
client.emitter.invoke(':Ecs', payload.ecs);
});
}, [ecsRef]);
usePacket('Tick', onTickPacket);
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.Inventory) {
if (mainEntityRef.current === id) {
setBufferSlot(entity.Inventory.item(0));
setHotbarSlots(() => {
const newHotbarSlots = [];
for (let i = 1; i < 11; ++i) {
newHotbarSlots.push(entity.Inventory.item(i));
}
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)
setExternalInventorySlots(newInventorySlots);
setIsInventoryOpen(true);
setHotbarIsHidden(false);
if (hotbarHideHandle.current) {
clearTimeout(hotbarHideHandle.current);
}
}
else if (update.Inventory.closed) {
setExternalInventory();
setExternalInventorySlots();
}
}
if (mainEntityRef.current === id) {
if (update.Wielder && 'activeSlot' in update.Wielder) {
setActiveSlot(update.Wielder.activeSlot);
}
}
}
}, [mainEntityRef]);
useEcsTick(onEcsTick);
const onEcsTickParticles = useCallback((payload, ecs) => {
if (!('1' in payload) || particleWorker) {
return
}
const localParticleWorker = new Worker(
new URL('./particle-worker.js', import.meta.url),
{type: 'module'},
);
localParticleWorker.postMessage(ecs.get(1).toJSON());
setParticleWorker((particleWorker) => {
if (particleWorker) {
particleWorker.terminate();
}
return 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, ecs) => {
if (mainEntityRef.current) {
const mainEntityEntity = ecs.get(mainEntityRef.current);
const x = Math.round((mainEntityEntity.Camera.x * scale) - RESOLUTION.x / 2);
const y = Math.round((mainEntityEntity.Camera.y * scale) - RESOLUTION.y / 2);
if (x !== camera.x || y !== camera.y) {
setCamera({x, y});
}
}
}, [camera, mainEntityRef, scale]);
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) {
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 hotbarOnActivate = useCallback((i) => {
keepHotbarOpen();
client.send({
type: 'Action',
payload: {type: 'swapSlots', value: [0, mainEntityRef.current, i + 1]},
});
}, [client, keepHotbarOpen, mainEntityRef]);
const bagOnActivate = useCallback((i) => {
client.send({
type: 'Action',
payload: {type: 'swapSlots', value: [0, mainEntityRef.current, i + 11]},
});
}, [client, mainEntityRef]);
return (
<div
className={styles.ui}
>
<style>
{`
@media (max-aspect-ratio: ${ratio}) { .${styles.game} { width: 100%; } }
@media (min-aspect-ratio: ${ratio}) { .${styles.game} { height: 100%; } }
.${styles.game} {
cursor: ${
bufferSlot
? `url('${bufferSlot.icon}'), auto !important`
: 'auto'
};
}
`}
</style>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */ }
<div
className={[styles.game, devtoolsIsOpen && styles.devtoolsIsOpen].filter(Boolean).join(' ')}
onMouseDown={(event) => {
if (chatInputRef.current) {
event.preventDefault();
return;
}
const where = computePosition(event);
switch (event.button) {
case 0:
if (devtoolsIsOpen) {
devEventsChannel.invoke(
'click',
where,
);
}
else {
client.send({
type: 'Action',
payload: {type: 'use', value: [1, where]},
});
}
break;
case 2:
if (monopolizers.current.length > 0) {
monopolizers.current[0].trigger();
break;
}
client.send({
type: 'Action',
payload: {type: 'interact', value: 1},
});
break;
}
}}
onMouseUp={(event) => {
if (chatInputRef.current) {
event.preventDefault();
return;
}
const where = computePosition(event);
switch (event.button) {
case 0:
client.send({
type: 'Action',
payload: {type: 'use', value: [0, where]},
});
break;
case 2:
client.send({
type: 'Action',
payload: {type: 'interact', value: 0},
});
break;
}
event.preventDefault();
}}
onWheel={(event) => {
if (chatInputRef.current) {
event.preventDefault();
return;
}
if (!isInventoryOpen) {
setHotbarIsHidden(false);
if (hotbarHideHandle.current) {
clearTimeout(hotbarHideHandle.current);
}
hotbarHideHandle.current = setTimeout(() => {
setHotbarIsHidden(true);
}, 4000);
}
if (event.deltaY > 0) {
client.send({
type: 'Action',
payload: {type: 'changeSlot', value: 1 + ((activeSlot + 1) % 10)},
});
}
else {
client.send({
type: 'Action',
payload: {type: 'changeSlot', value: 1 + ((activeSlot + 9) % 10)},
});
}
}}
ref={gameRef}
>
<Pixi
camera={camera}
monopolizers={monopolizers.current}
particleWorker={particleWorker}
scale={scale}
/>
<Dom>
<HotBar
active={activeSlot}
hotbarIsHidden={hotbarIsHidden}
onActivate={hotbarOnActivate}
slots={hotbarSlots}
/>
<Bag
isInventoryOpen={isInventoryOpen}
onActivate={bagOnActivate}
slots={inventorySlots}
/>
{externalInventory && (
<External
isInventoryOpen={isInventoryOpen}
onActivate={(i) => {
client.send({
type: 'Action',
payload: {type: 'swapSlots', value: [0, externalInventory, i]},
});
}}
slots={externalInventorySlots}
/>
)}
<Entities
camera={camera}
scale={scale}
setChatMessages={setChatMessages}
monopolizers={monopolizers.current}
/>
{chatIsOpen && (
<Chat
chatHistory={chatHistory}
chatHistoryCaret={chatHistoryCaret}
chatInputRef={chatInputRef}
chatMessages={chatMessages}
message={message}
pendingMessage={pendingMessage}
setChatHistory={setChatHistory}
setChatHistoryCaret={setChatHistoryCaret}
setMessage={setMessage}
setPendingMessage={setPendingMessage}
/>
)}
{showDisconnected && (
<Disconnected />
)}
</Dom>
</div>
{devtoolsIsOpen && (
<div className={[styles.devtools, devtoolsIsOpen && styles.devtoolsIsOpen].filter(Boolean).join(' ')}>
<Devtools
eventsChannel={devEventsChannel}
/>
</div>
)}
</div>
);
}
export default Ui;