Compare commits

..

5 Commits

Author SHA1 Message Date
cha0s
2c3a1bbdb6 refactor: basic connection 2024-05-26 13:02:16 -05:00
cha0s
872a001877 refactor: ui owns key input 2024-05-26 12:33:52 -05:00
cha0s
243574ff79 chore: tidy 2024-05-26 12:07:04 -05:00
cha0s
97791cc51e feat: socket 2024-05-26 12:05:59 -05:00
cha0s
eae2871472 refactor: dude movement 2024-05-26 01:12:00 -05:00
19 changed files with 275 additions and 126 deletions

4
package-lock.json generated
View File

@ -11,7 +11,8 @@
"@pixi/react": "^7.1.2", "@pixi/react": "^7.1.2",
"pixi.js": "^8.1.5", "pixi.js": "^8.1.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"ws": "^8.17.0"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^1.4.0", "@chromatic-com/storybook": "^1.4.0",
@ -12678,7 +12679,6 @@
"version": "8.17.0", "version": "8.17.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz",
"integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==",
"dev": true,
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },

View File

@ -22,15 +22,16 @@
"@storybook/react": "^8.1.3", "@storybook/react": "^8.1.3",
"@storybook/react-vite": "^8.1.3", "@storybook/react-vite": "^8.1.3",
"@storybook/test": "^8.1.3", "@storybook/test": "^8.1.3",
"@vitejs/plugin-react": "^4.3.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"storybook": "^8.1.3", "storybook": "^8.1.3",
"vite": "^5.2.11", "vite": "^5.2.11"
"@vitejs/plugin-react": "^4.3.0"
}, },
"dependencies": { "dependencies": {
"@pixi/react": "^7.1.2", "@pixi/react": "^7.1.2",
"pixi.js": "^8.1.5", "pixi.js": "^8.1.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"ws": "^8.17.0"
} }
} }

View File

@ -1,6 +1,6 @@
// Handle lame OS key input event behavior. See: https://mzl.la/2Ob0WQE // Handle lame OS key input event behavior. See: https://mzl.la/2Ob0WQE
// Also "up" all keys on blur. // Also "up" all keys on blur.
export default function addKeyInputListener(target, listener) { export default function addKeyListener(target, listener) {
let keysDown = {}; let keysDown = {};
let keyUpDelays = {}; let keyUpDelays = {};
function setAllKeysUp() { function setAllKeysUp() {

View File

@ -1,49 +0,0 @@
import {
Application,
Assets,
Sprite,
} from 'pixi.js';
import {createElement} from 'react';
import {createRoot} from 'react-dom/client';
import addKeyInputListener from './add-key-input-listener.js';
import Silphius from './components/silphius.jsx';
import ServerContext from './context/server.js';
// Setup server connection.
import Local from './server/local.js';
const server = new Local();
server.send({type: 'connect'});
// Handle input.
const ACTION_MAP = {
w: 'moveUp',
d: 'moveRight',
s: 'moveDown',
a: 'moveLeft',
};
const KEY_MAP = {
keyDown: 1,
keyUp: 0,
};
addKeyInputListener(document.body, ({type, payload}) => {
if (type in KEY_MAP && payload in ACTION_MAP) {
server.send({
type: 'action',
payload: {
type: ACTION_MAP[payload],
value: KEY_MAP[type],
},
});
}
});
// Setup DOM.
createRoot(document.querySelector('.silphius'))
.render(
createElement(
ServerContext.Provider,
{value: server},
[createElement(Silphius, {key: 'silphius'})],
),
);

View File

@ -1,8 +1,45 @@
import {useEffect, useState} from 'react';
import ServerContext from '../context/server.js';
import Title from './title';
import Ui from './ui'; import Ui from './ui';
export default function Silphius() { export default function Silphius() {
const connectionTuple = useState();
const [server, setServer] = useState();
useEffect(() => {
if (!connectionTuple[0]) {
return;
}
async function connect() {
let Server;
switch (connectionTuple[0]) {
case 'local':
({default: Server} = await import('../server/local.js'));
break;
case 'remote':
({default: Server} = await import('../server/remote.js'));
break;
}
const server = new Server();
await server.connect();
server.send({type: 'connect'});
setServer(server);
}
connect();
}, [connectionTuple[0]]);
return ( return (
connectionTuple[0]
? (
server
? (
<ServerContext.Provider value={server}>
<Ui /> <Ui />
</ServerContext.Provider>
)
: null
)
: <Title connectionTuple={connectionTuple} />
); );
} }

25
src/components/title.jsx Normal file
View File

@ -0,0 +1,25 @@
import styles from './title.module.css';
export default function Title({connectionTuple}) {
return (
<div className={styles.title}>
<h1>Silphius</h1>
<ul>
<li
onClick={() => {
connectionTuple[1]('local')
}}
>
Local
</li>
<li
onClick={() => {
connectionTuple[1]('remote')
}}
>
Remote
</li>
</ul>
</div>
)
}

View File

@ -0,0 +1,13 @@
.title h1 {
font-size: 10em;
}
.title ul {
padding-inline-start: 0;
}
.title li {
font-size: 4em;
list-style: none;
padding: 0.25em 0;
}

View File

@ -1,11 +1,42 @@
import {useContext, useEffect} from 'react';
import addKeyListener from '../add-key-listener.js';
import {RESOLUTION} from '../constants'; import {RESOLUTION} from '../constants';
import ServerContext from '../context/server';
import Dom from './dom'; import Dom from './dom';
import Pixi from './pixi'; import Pixi from './pixi';
import styles from './ui.module.css'; import styles from './ui.module.css';
const ratio = RESOLUTION[0] / RESOLUTION[1]; const ratio = RESOLUTION[0] / RESOLUTION[1];
// Handle input.
const ACTION_MAP = {
w: 'moveUp',
d: 'moveRight',
s: 'moveDown',
a: 'moveLeft',
};
const KEY_MAP = {
keyDown: 1,
keyUp: 0,
};
export default function Ui() { export default function Ui() {
// Key input.
const server = useContext(ServerContext);
useEffect(() => {
return addKeyListener(document.body, ({type, payload}) => {
if (type in KEY_MAP && payload in ACTION_MAP) {
server.send({
type: 'action',
payload: {
type: ACTION_MAP[payload],
value: KEY_MAP[type],
},
});
}
});
});
return ( return (
<div className={styles.ui}> <div className={styles.ui}>
<style> <style>

View File

@ -1,3 +1,4 @@
.ui { .ui {
align-self: center;
position: relative; position: relative;
} }

View File

@ -8,9 +8,9 @@ html, body {
} }
.silphius { .silphius {
align-items: center;
display: flex; display: flex;
height: 100%; height: 100%;
justify-content: space-around; justify-content: space-around;
line-height: 1;
width: 100%; width: 100%;
} }

View File

@ -6,7 +6,7 @@
<link rel="stylesheet" href="./index.css"> <link rel="stylesheet" href="./index.css">
</head> </head>
<body> <body>
<script type="module" src="./client.js"></script> <script type="module" src="./index.js"></script>
<div class="silphius"></div> <div class="silphius"></div>
</body> </body>
</html> </html>

8
src/index.js Normal file
View File

@ -0,0 +1,8 @@
import {createElement} from 'react';
import {createRoot} from 'react-dom/client';
import Silphius from './components/silphius.jsx';
// Setup DOM.
createRoot(document.querySelector('.silphius'))
.render(createElement(Silphius));

65
src/server/engine.js Normal file
View File

@ -0,0 +1,65 @@
const SPEED = 100;
const TPS = 60;
const MOVE_MAP = {
'moveUp': 0,
'moveRight': 1,
'moveDown': 2,
'moveLeft': 3,
};
export default class Engine {
constructor() {
this.dude = {
image: './assets/bunny.png',
movement: [0, 0, 0, 0],
position: [50, 50],
};
this.frame = 0;
this.last = Date.now();
}
accept({type, payload}) {
switch (type) {
case 'connect': {
this.send({type: 'connected', payload: {dude: this.dude}});
break;
}
case 'action': {
if (payload.type in MOVE_MAP) {
this.dude.movement[MOVE_MAP[payload.type]] = payload.value;
}
break;
}
default:
}
}
send(data) {
throw new Exception('Engine::send is virtual!');
}
start() {
return setInterval(() => {
const elapsed = (Date.now() - this.last) / 1000;
this.last = Date.now();
this.tick(elapsed);
}, 1000 / TPS);
}
tick(elapsed) {
this.dude.position[0] += SPEED * elapsed * (this.dude.movement[1] - this.dude.movement[3]);
this.dude.position[1] += SPEED * elapsed * (this.dude.movement[2] - this.dude.movement[0]);
this.send({
type: 'tick',
payload: {
entities: {dude: this.dude},
elapsed,
frame: this.frame,
},
});
this.frame += 1;
}
}

View File

@ -1,22 +1,12 @@
export default class Local { import Server from './server.js';
constructor() {
this.listeners = []; export default class Local extends Server {
this.worker = new Worker('/server/server.js'); async connect() {
this.worker = new Worker('/server/worker.js', {type: 'module'});
this.worker.onmessage = (event) => { this.worker.onmessage = (event) => {
for (const i in this.listeners) { this.accept(event.data);
this.listeners[i](event.data);
}
}; };
} }
addMessageListener(listener) {
this.listeners.push(listener);
}
removeMessageListener(listener) {
const index = this.listeners.indexOf(listener);
if (-1 !== index) {
this.listeners.splice(index, 1);
}
}
send(message) { send(message) {
this.worker.postMessage(message); this.worker.postMessage(message);
} }

19
src/server/remote.js Normal file
View File

@ -0,0 +1,19 @@
import Server from './server.js';
export default class Remote extends Server {
async connect() {
this.socket = new WebSocket('/ws');
this.socket.onmessage = (event) => {
for (const i in this.listeners) {
this.listeners[i](JSON.parse(event.data));
}
};
await new Promise((resolve) => {
this.socket.onopen = resolve;
});
}
send(message) {
this.socket.send(JSON.stringify(message));
}
}

View File

@ -1,54 +1,19 @@
const SPEED = 100; export default class Server {
const TPS = 60; constructor() {
this.listeners = [];
const dude = {
image: './assets/bunny.png',
position: [50, 50],
};
let frame = 0;
const movement = [0, 0, 0, 0];
const MOVE_MAP = {
'moveUp': 0,
'moveRight': 1,
'moveDown': 2,
'moveLeft': 3,
};
onmessage = ({data}) => {
const {type, payload} = data;
switch (type) {
case 'connect': {
postMessage({type: 'connected', payload: {dude}});
break;
} }
case 'action': { accept(data) {
if (payload.type in MOVE_MAP) { for (const i in this.listeners) {
movement[MOVE_MAP[payload.type]] = payload.value; this.listeners[i](data);
} }
break;
} }
default: addMessageListener(listener) {
this.listeners.push(listener);
}
removeMessageListener(listener) {
const index = this.listeners.indexOf(listener);
if (-1 !== index) {
this.listeners.splice(index, 1);
}
} }
};
function tick(elapsed) {
dude.position[0] += SPEED * elapsed * (movement[1] - movement[3]);
dude.position[1] += SPEED * elapsed * (movement[2] - movement[0]);
postMessage({
type: 'tick',
payload: {
entities: {dude},
elapsed,
frame,
},
});
frame += 1;
} }
let last = Date.now();
setInterval(() => {
const elapsed = (Date.now() - last) / 1000;
last = Date.now();
tick(elapsed);
}, 1000 / TPS);

19
src/server/socket.js Normal file
View File

@ -0,0 +1,19 @@
import {WebSocketServer} from 'ws';
import Engine from './engine.js';
const {
SILPHIUS_WEBSOCKET_PORT = 8080
} = process.env;
const wss = new WebSocketServer({port: SILPHIUS_WEBSOCKET_PORT});
wss.on('connection', function connection(ws) {
const server = new class WorkerEngine extends Engine {
send(data) { ws.send(JSON.stringify(data)); }
}
ws.on('message', function message(data) {
server.accept(JSON.parse(data));
});
server.start();
});

9
src/server/worker.js Normal file
View File

@ -0,0 +1,9 @@
import Engine from './engine.js';
const server = new class WorkerEngine extends Engine {
send(data) { postMessage(data); }
}
onmessage = ({data}) => { server.accept(data); };
server.start();

View File

@ -1,6 +1,21 @@
import {defineConfig} from 'vite' import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
const {
SILPHIUS_WEBSOCKET_PORT = 8080
} = process.env;
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
host: true,
proxy: {
'^/ws$': {
target: `ws://127.0.0.1:${SILPHIUS_WEBSOCKET_PORT}`,
ws: true,
changeOrigin: true,
rewrite: path => path.replace(/^\/ws/, '')
}
}
}
}); });