feat: chat and dialogue++

This commit is contained in:
cha0s 2024-07-14 07:24:15 -05:00
parent dd456743f8
commit 5a4666ae49
15 changed files with 306 additions and 158 deletions

View File

@ -1,8 +1,11 @@
import {createElement} from 'react';
import mdx from 'remark-mdx'; import mdx from 'remark-mdx';
import parse from 'remark-parse'; import parse from 'remark-parse';
import {unified} from 'unified'; import {unified} from 'unified';
import {visitParents as visit} from 'unist-util-visit-parents'; import {visitParents as visit} from 'unist-util-visit-parents';
import {TAU} from '@/util/math.js';
const parser = unified().use(parse).use(mdx); const parser = unified().use(parse).use(mdx);
function computeParams(ancestors) { function computeParams(ancestors) {
@ -54,3 +57,65 @@ export function parseLetters(source) {
}); });
return letters; return letters;
} }
export function render(letters, className) {
return (caret, radians) => (
letters
.map(({character, indices, params}, i) => {
let color = 'inherit';
let fade = 0;
let fontStyle = 'normal';
let fontWeight = 'normal';
let left = 0;
let opacity = 1;
let top = 0;
if (params.blink) {
const {frequency = 1} = params.blink;
opacity = (radians * (2 / frequency) % TAU) > (TAU / 2) ? opacity : 0;
}
if (params.fade) {
const {frequency = 1} = params.fade;
fade = frequency;
}
if (params.wave) {
const {frequency = 1, magnitude = 3} = params.wave;
top += magnitude * Math.cos((radians * (1 / frequency)) + TAU * indices.wave / params.wave.length);
}
if (params.rainbow) {
const {frequency = 1} = params.rainbow;
color = `hsl(${(radians * (1 / frequency)) + TAU * indices.rainbow / params.rainbow.length}rad 100 50)`;
}
if (params.shake) {
const {magnitude = 1} = params.shake;
left += (Math.random() * magnitude * 2) - magnitude;
top += (Math.random() * magnitude * 2) - magnitude;
}
if (params.em) {
fontStyle = 'italic';
}
if (params.strong) {
fontWeight = 'bold';
}
return (
createElement(
'span',
{
className,
key: i,
style: {
color,
fontStyle,
fontWeight,
left: `${left}px`,
opacity: i <= caret ? opacity : 0,
position: 'relative',
top: `${top}px`,
transition: `opacity ${fade}s`,
},
},
character,
)
);
})
);
}

View File

@ -1,91 +0,0 @@
import {useEffect, useState} from 'react';
import {useClient} from '@/context/client.js';
import styles from './chat.module.css';
export default function Chat({
chatHistory,
onClose,
message,
setChatHistory,
setMessage,
}) {
const client = useClient();
const [disabled, setDisabled] = useState(true);
const [historyCaret, setHistoryCaret] = useState(0);
useEffect(() => {
setDisabled(false);
}, []);
return (
<div className={styles.chat}>
<form
onSubmit={(event) => {
if (message) {
client.send({
type: 'Action',
payload: {type: 'chat', value: message},
});
setChatHistory([message, ...chatHistory]);
setMessage('');
onClose();
}
event.preventDefault();
}}
>
<input
disabled={disabled}
onChange={(event) => {
setMessage(event.target.value);
}}
onKeyDown={(event) => {
switch (event.key) {
case 'ArrowDown': {
if (0 === historyCaret) {
break;
}
let localHistoryCaret = historyCaret - 1;
setMessage(chatHistory[localHistoryCaret])
setHistoryCaret(localHistoryCaret);
if (0 === localHistoryCaret) {
setChatHistory(chatHistory.slice(1));
}
break;
}
case 'ArrowUp': {
if (historyCaret === chatHistory.length - 1) {
break;
}
let localHistoryCaret = historyCaret;
let localChatHistory = chatHistory;
if (0 === historyCaret) {
localChatHistory = [message, ...localChatHistory];
setChatHistory(localChatHistory);
}
localHistoryCaret += 1;
setMessage(localChatHistory[localHistoryCaret])
setHistoryCaret(localHistoryCaret);
break;
}
case 'Escape': {
onClose();
break;
}
}
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
maxLength="255"
ref={(element) => {
if (element) {
element.focus();
}
}}
type="text"
value={message}
/>
</form>
</div>
);
}

View File

@ -0,0 +1,28 @@
import styles from './chat.module.css';
import Input from './input.jsx';
import Messages from './messages.jsx';
export default function Chat({
chatHistory,
chatMessages,
onClose,
message,
setChatHistory,
setMessage,
}) {
return (
<div className={styles.chat}>
<Messages
chatMessages={chatMessages}
/>
<Input
chatHistory={chatHistory}
onClose={onClose}
message={message}
setChatHistory={setChatHistory}
setMessage={setMessage}
/>
</div>
);
}

View File

@ -0,0 +1,15 @@
.chat {
background-color: #00000044;
display: flex;
flex-direction: column;
font-family: Cookbook, Georgia, 'Times New Roman', Times, serif;
position: absolute;
bottom: 0;
max-height: 25%;
width: 100%;
}
@font-face {
font-family: "Cookbook";
src: url("/assets/fonts/Cookbook.woff") format("woff");
}

View File

@ -0,0 +1,84 @@
import {useEffect, useState} from 'react';
import {useClient} from '@/context/client.js';
import styles from './input.module.css';
export default function ChatInput({
chatHistory,
message,
setChatHistory,
setMessage,
}) {
const client = useClient();
const [disabled, setDisabled] = useState(true);
const [historyCaret, setHistoryCaret] = useState(0);
useEffect(() => {
setDisabled(false);
}, []);
return (
<form
className={styles.input}
onSubmit={(event) => {
if (message) {
client.send({
type: 'Action',
payload: {type: 'chat', value: message},
});
setChatHistory([message, ...chatHistory]);
setMessage('');
}
event.preventDefault();
}}
>
<input
disabled={disabled}
onChange={(event) => {
setMessage(event.target.value);
}}
onKeyDown={(event) => {
switch (event.key) {
case 'ArrowDown': {
if (0 === historyCaret) {
break;
}
let localHistoryCaret = historyCaret - 1;
setMessage(chatHistory[localHistoryCaret])
setHistoryCaret(localHistoryCaret);
if (0 === localHistoryCaret) {
setChatHistory(chatHistory.slice(1));
}
break;
}
case 'ArrowUp': {
if (historyCaret === chatHistory.length - 1) {
break;
}
let localHistoryCaret = historyCaret;
let localChatHistory = chatHistory;
if (0 === historyCaret) {
localChatHistory = [message, ...localChatHistory];
setChatHistory(localChatHistory);
}
localHistoryCaret += 1;
setMessage(localChatHistory[localHistoryCaret])
setHistoryCaret(localHistoryCaret);
break;
}
}
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
maxLength="255"
ref={(element) => {
if (element) {
element.focus();
}
}}
type="text"
value={message}
/>
</form>
);
}

View File

@ -1,17 +1,15 @@
.chat {
background-color: #00000044;
position: absolute;
bottom: 0;
width: 100%;
}
.chat form { .input {
line-height: 0; line-height: 0;
input[type="text"] { input[type="text"] {
background-color: #00000044; background-color: #00000044;
border: 1px solid #333333; border: 1px solid #333333;
color: #ffffff; color: #ffffff;
font-family: "Cookbook";
font-size: 16px;
margin: 4px; margin: 4px;
padding: 0;
padding-left: 4px;
width: calc(100% - 8px); width: calc(100% - 8px);
&:focus-visible { &:focus-visible {
border: 1px solid #999999; border: 1px solid #999999;

View File

@ -0,0 +1,33 @@
import {useEffect, useState} from 'react';
import {render} from '@/dialogue.js';
import {TAU} from '@/util/math.js';
import styles from './message.module.css';
export default function Message({letters}) {
const [radians, setRadians] = useState(0);
useEffect(() => {
setRadians(0);
let handle;
let last;
const spin = (ts) => {
if ('undefined' === typeof last) {
last = ts;
}
const elapsed = (ts - last) / 1000;
last = ts;
setRadians((radians) => radians + (elapsed * TAU));
handle = requestAnimationFrame(spin);
};
handle = requestAnimationFrame(spin);
return () => {
cancelAnimationFrame(handle);
};
}, []);
return (
<div className={styles.message}>
{render(letters, '')(letters.length - 1, radians)}
</div>
)
}

View File

@ -0,0 +1,4 @@
.message {
color: #ffffff;
margin-left: 8px;
}

View File

@ -0,0 +1,16 @@
import Message from './message.jsx';
import styles from './messages.module.css';
export default function Messages({chatMessages}) {
return (
<div className={styles.messages}>
{chatMessages.map((letters, i) => (
<Message
key={i}
letters={letters}
/>
))}
</div>
)
}

View File

@ -0,0 +1,5 @@
.messages {
display: flex;
flex-direction: column-reverse;
overflow-y: auto;
}

View File

@ -1,7 +1,8 @@
import {useEffect, useRef, useState} from 'react'; import {useEffect, useMemo, useRef, useState} from 'react';
import {RESOLUTION} from '@/constants.js'; import {RESOLUTION} from '@/constants.js';
import {useDomScale} from '@/context/dom-scale.js'; import {useDomScale} from '@/context/dom-scale.js';
import {render} from '@/dialogue.js';
import {TAU} from '@/util/math.js'; import {TAU} from '@/util/math.js';
import styles from './dialogue.module.css'; import styles from './dialogue.module.css';
@ -88,6 +89,10 @@ export default function Dialogue({
cancelAnimationFrame(handle); cancelAnimationFrame(handle);
}; };
}, [dialogue, domScale, ref]); }, [dialogue, domScale, ref]);
const localRender = useMemo(
() => render(dialogue.letters, styles.letter),
[dialogue.letters],
);
const origin = 'function' === typeof dialogue.origin const origin = 'function' === typeof dialogue.origin
? dialogue.origin() ? dialogue.origin()
: dialogue.origin || {x: 0, y: 0}; : dialogue.origin || {x: 0, y: 0};
@ -175,62 +180,7 @@ export default function Dialogue({
<polygon points="0 0, 24 0, 12 24" /> <polygon points="0 0, 24 0, 12 24" />
</svg> </svg>
<p className={styles.letters}> <p className={styles.letters}>
{ {localRender(caret, radians)}
dialogue.letters
.map(({character, indices, params}, i) => {
let color = 'inherit';
let fade = 0;
let fontStyle = 'normal';
let fontWeight = 'normal';
let left = 0;
let opacity = 1;
let top = 0;
if (params.blink) {
const {frequency = 1} = params.blink;
opacity = (radians * (2 / frequency) % TAU) > (TAU / 2) ? opacity : 0;
}
if (params.fade) {
const {frequency = 1} = params.fade;
fade = frequency;
}
if (params.wave) {
const {frequency = 1, magnitude = 3} = params.wave;
top += magnitude * Math.cos((radians * (1 / frequency)) + TAU * indices.wave / params.wave.length);
}
if (params.rainbow) {
const {frequency = 1} = params.rainbow;
color = `hsl(${(radians * (1 / frequency)) + TAU * indices.rainbow / params.rainbow.length}rad 100 50)`;
}
if (params.shake) {
const {magnitude = 1} = params.shake;
left += (Math.random() * magnitude * 2) - magnitude;
top += (Math.random() * magnitude * 2) - magnitude;
}
if (params.em) {
fontStyle = 'italic';
}
if (params.strong) {
fontWeight = 'bold';
}
return (
<span
className={styles.letter}
key={i}
style={{
color,
fontStyle,
fontWeight,
left: `${left}px`,
opacity: i <= caret ? opacity : 0,
top: `${top}px`,
transition: `opacity ${fade}s`,
}}
>
{character}
</span>
);
})
}
</p> </p>
</div> </div>
); );

View File

@ -11,6 +11,7 @@
border: solid 1px white; border: solid 1px white;
border-radius: 8px; border-radius: 8px;
color: white; color: white;
overflow-wrap: break-word;
padding: 1em; padding: 1em;
position: fixed; position: fixed;
margin: 0; margin: 0;
@ -26,5 +27,4 @@
.letter { .letter {
opacity: 0; opacity: 0;
position: relative;
} }

View File

@ -6,7 +6,12 @@ import {parseLetters} from '@/dialogue.js';
import Entity from './entity.jsx'; import Entity from './entity.jsx';
export default function Entities({camera, scale, setMonopolizers}) { export default function Entities({
camera,
scale,
setChatMessages,
setMonopolizers,
}) {
const [ecs] = useEcs(); const [ecs] = useEcs();
const [entities, setEntities] = useState({}); const [entities, setEntities] = useState({});
usePacket('EcsChange', async () => { usePacket('EcsChange', async () => {
@ -34,6 +39,7 @@ export default function Entities({camera, scale, setMonopolizers}) {
for (const key in dialogue) { for (const key in dialogue) {
dialogues[key] = dialogue[key]; dialogues[key] = dialogue[key];
dialogues[key].letters = parseLetters(dialogues[key].body); dialogues[key].letters = parseLetters(dialogues[key].body);
setChatMessages((chatMessages) => [dialogues[key].letters, ...chatMessages]);
const skipListeners = new Set(); const skipListeners = new Set();
dialogues[key].addSkipListener = (listener) => { dialogues[key].addSkipListener = (listener) => {
skipListeners.add(listener); skipListeners.add(listener);

View File

@ -9,7 +9,7 @@ import {useEcs, useEcsTick} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js'; import {useMainEntity} from '@/context/main-entity.js';
import Disconnected from './dom/disconnected.jsx'; import Disconnected from './dom/disconnected.jsx';
import Chat from './dom/chat.jsx'; import Chat from './dom/chat/chat.jsx';
import Dom from './dom/dom.jsx'; import Dom from './dom/dom.jsx';
import Entities from './dom/entities.jsx'; import Entities from './dom/entities.jsx';
import HotBar from './dom/hotbar.jsx'; import HotBar from './dom/hotbar.jsx';
@ -69,6 +69,7 @@ export default function Ui({disconnected}) {
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [chatIsOpen, setChatIsOpen] = useState(false); const [chatIsOpen, setChatIsOpen] = useState(false);
const [chatHistory, setChatHistory] = useState([]); const [chatHistory, setChatHistory] = useState([]);
const [chatMessages, setChatMessages] = useState([]);
useEffect(() => { useEffect(() => {
async function setEcsStuff() { async function setEcsStuff() {
const {default: Components} = await import('@/ecs-components/index.js'); const {default: Components} = await import('@/ecs-components/index.js');
@ -97,6 +98,10 @@ export default function Ui({disconnected}) {
}, [disconnected]); }, [disconnected]);
useEffect(() => { useEffect(() => {
return addKeyListener(document.body, ({event, type, payload}) => { return addKeyListener(document.body, ({event, type, payload}) => {
if ('Escape' === payload && 'keyDown' === type && chatIsOpen) {
setChatIsOpen(false);
return;
}
if (chatIsOpen) { if (chatIsOpen) {
return; return;
} }
@ -323,6 +328,10 @@ export default function Ui({disconnected}) {
<div <div
className={[styles.game, devtoolsIsOpen && styles.devtoolsIsOpen].filter(Boolean).join(' ')} className={[styles.game, devtoolsIsOpen && styles.devtoolsIsOpen].filter(Boolean).join(' ')}
onMouseDown={(event) => { onMouseDown={(event) => {
if (chatIsOpen) {
event.preventDefault();
return;
}
switch (event.button) { switch (event.button) {
case 0: case 0:
if (devtoolsIsOpen) { if (devtoolsIsOpen) {
@ -372,6 +381,10 @@ export default function Ui({disconnected}) {
} }
}} }}
onMouseUp={(event) => { onMouseUp={(event) => {
if (chatIsOpen) {
event.preventDefault();
return;
}
switch (event.button) { switch (event.button) {
case 0: case 0:
client.send({ client.send({
@ -389,6 +402,10 @@ export default function Ui({disconnected}) {
event.preventDefault(); event.preventDefault();
}} }}
onWheel={(event) => { onWheel={(event) => {
if (chatIsOpen) {
event.preventDefault();
return;
}
if (event.deltaY > 0) { if (event.deltaY > 0) {
client.send({ client.send({
type: 'Action', type: 'Action',
@ -424,11 +441,13 @@ export default function Ui({disconnected}) {
<Entities <Entities
camera={camera} camera={camera}
scale={scale} scale={scale}
setChatMessages={setChatMessages}
setMonopolizers={setMonopolizers} setMonopolizers={setMonopolizers}
/> />
{chatIsOpen && ( {chatIsOpen && (
<Chat <Chat
chatHistory={chatHistory} chatHistory={chatHistory}
chatMessages={chatMessages}
message={message} message={message}
setChatHistory={setChatHistory} setChatHistory={setChatHistory}
setMessage={setMessage} setMessage={setMessage}

View File

@ -10,3 +10,19 @@ html, body {
line-height: 1; line-height: 1;
} }
} }
body {
scrollbar-width: thin;
scrollbar-color: #777 #333;
}
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: #333;
}
::-webkit-scrollbar-thumb {
background-color: #777;
border-radius: 20px;
border: 3px solid #333;
}