chore: initial

This commit is contained in:
cha0s 2024-05-25 13:54:43 -05:00
commit 37918907b4
33 changed files with 13520 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/indev
/node_modules

15
.storybook/main.js Normal file
View File

@ -0,0 +1,15 @@
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@chromatic-com/storybook',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;

13
.storybook/preview.js Normal file
View File

@ -0,0 +1,13 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

54
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,54 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Silphius Dev",
"skipFiles": [
"<node_internals>/**"
],
"resolveSourceMapLocations": [],
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--host", "0.0.0.0"],
},
{
"type": "node",
"request": "launch",
"name": "Storybook Dev",
"skipFiles": [
"<node_internals>/**"
],
"resolveSourceMapLocations": [],
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "storybook", "--", "--no-open"],
},
{
"type": "chrome",
"request": "launch",
"name": "Chrome",
"url": "",
"webRoot": "${workspaceFolder}",
"runtimeArgs": [
"http://localhost:5173",
"http://localhost:6006",
]
},
],
"compounds": [
{
"name": "Silphius",
"configurations": [
"Silphius Dev",
"Storybook Dev",
"Chrome",
],
"stopAll": true,
}
]
}

12726
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "silphius",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "npm run vite",
"build": "npm run vite build",
"preview": "npm run vite preview",
"start": "npm run dev",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build",
"vite": "vite --config vite.config.js src"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.4.0",
"@storybook/addon-essentials": "^8.1.3",
"@storybook/addon-interactions": "^8.1.3",
"@storybook/addon-links": "^8.1.3",
"@storybook/addon-onboarding": "^8.1.3",
"@storybook/blocks": "^8.1.3",
"@storybook/react": "^8.1.3",
"@storybook/react-vite": "^8.1.3",
"@storybook/test": "^8.1.3",
"prop-types": "^15.8.1",
"storybook": "^8.1.3",
"vite": "^5.2.11",
"@vitejs/plugin-react": "^4.3.0"
},
"dependencies": {
"@pixi/react": "^7.1.2",
"pixi.js": "^8.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}

View File

@ -0,0 +1,48 @@
// Handle lame OS key input event behavior. See: https://mzl.la/2Ob0WQE
// Also "up" all keys on blur.
export default function addKeyInputListener(target, listener) {
let keysDown = {};
let keyUpDelays = {};
function setAllKeysUp() {
for (const i in keyUpDelays) {
clearTimeout(keyUpDelays[i]);
}
keyUpDelays = {};
for (const key in keysDown) {
listener({type: 'keyUp', payload: key});
}
keysDown = {};
}
function onBlur() {
setAllKeysUp();
}
function onKeyDown(event) {
const {key} = event;
if (keysDown[key]) {
if (keyUpDelays[key]) {
clearTimeout(keyUpDelays[key]);
delete keyUpDelays[key];
}
return;
}
keysDown[key] = true;
listener({type: 'keyDown', payload: key});
}
function onKeyUp(event) {
const {key} = event;
keyUpDelays[key] = setTimeout(() => {
delete keyUpDelays[key];
delete keysDown[key];
listener({type: 'keyUp', payload: key});
}, 20);
}
window.addEventListener('blur', onBlur);
window.addEventListener('keyup', onKeyUp);
target.addEventListener('keydown', onKeyDown);
return () => {
setAllKeysUp();
target.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
};
}

BIN
src/assets/bunny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

BIN
src/assets/potion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

49
src/client.js Normal file
View File

@ -0,0 +1,49 @@
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'})],
),
);

36
src/components/dom.jsx Normal file
View File

@ -0,0 +1,36 @@
import {useEffect, useRef, useState} from 'react';
import {RESOLUTION} from '../constants';
import styles from './dom.module.css';
/**
* Dom autoscales with resolution.
*/
export default function Dom({children}) {
const ref = useRef();
const [scale, setScale] = useState(0);
useEffect(() => {
if (!ref.current) {
return;
}
function onResize() {
const {parentNode} = ref.current;
const {width} = parentNode.getBoundingClientRect();
setScale(width / RESOLUTION[0]);
}
window.addEventListener('resize', onResize);
onResize();
return () => {
window.removeEventListener('resize', onResize);
}
}, [ref.current]);
return (
<div className={styles.dom} ref={ref}>
{scale > 0 && (
<style>{`.${styles.dom}{--scale:${scale}}`}</style>
)}
{children}
</div>
);
}

View File

@ -0,0 +1,8 @@
.dom {
height: calc(100% / var(--scale));
position: absolute;
top: 0;
transform: scale(var(--scale));
transform-origin: top left;
width: calc(100% / var(--scale));
}

View File

@ -0,0 +1,24 @@
import {
Sprite,
} from '@pixi/react';
import {useContext, useEffect, useState} from 'react';
import ServerContext from '../context/server';
export default function Entities({entities}) {
return (
<>
{
Object.values(entities)
.map(({image, position: [x, y]}, i) => (
<Sprite
image={image}
key={i}
x={x}
y={y}
/>
))
}
</>
)
}

28
src/components/hotbar.jsx Normal file
View File

@ -0,0 +1,28 @@
import Slot from './slot';
import styles from './hotbar.module.css';
/**
* The hotbar. 10 slots of inventory with an active selection.
*/
export default function Hotbar({active, onActivate, slots}) {
const Slots = slots.map((slot, i) => (
<div
className={
[styles.slotWrapper, active === i && styles.active]
.filter(Boolean).join(' ')
}
onClick={() => onActivate(i)}
key={i}
>
<Slot {...slot} />
</div>
));
return (
<div
className={styles.hotbar}
>
{Slots}
</div>
);
}

View File

@ -0,0 +1,25 @@
.hotbar {
border: 2px solid #999999;
box-sizing: border-box;
display: inline-block;
left: 135px;
line-height: 0;
position: absolute;
top: 20px;
}
.slotWrapper {
border: 2px solid #999999;
box-sizing: border-box;
display: inline-block;
line-height: 0;
&.active {
border-color: yellow;
}
&:hover {
background-color: rgba(0, 0, 0, 0.1);
cursor: pointer;
}
}

48
src/components/pixi.jsx Normal file
View File

@ -0,0 +1,48 @@
import {
Stage,
Container,
} from '@pixi/react';
import {useContext, useEffect, useState} from 'react';
import {RESOLUTION} from '../constants.js';
import ServerContext from '../context/server';
import Entities from './entities';
import styles from './pixi.module.css';
export default function Pixi() {
const server = useContext(ServerContext);
const [entities, setEntities] = useState({});
useEffect(() => {
function onMessage(message) {
const {type, payload} = message;
switch (type) {
case 'connected': {
setEntities(payload);
break;
}
case 'tick': {
setEntities(payload.entities);
break;
}
default:
}
}
server.addMessageListener(onMessage);
return () => {
server.removeMessageListener(onMessage);
};
});
return (
<Stage
className={styles.stage}
width={RESOLUTION[0]}
height={RESOLUTION[1]}
options={{
background: 0x1099bb,
}}
>
<Entities entities={entities} />
</Stage>
);
}

View File

@ -0,0 +1,7 @@
.stage {
height: 100% !important;
image-rendering: pixelated;
line-height: 0;
position: relative;
width: 100% !important;
}

View File

@ -0,0 +1,8 @@
import Ui from './ui';
export default function Silphius() {
return (
<Ui />
);
}

View File

29
src/components/slot.jsx Normal file
View File

@ -0,0 +1,29 @@
import styles from './slot.module.css';
/**
* An inventory slot. Displays an item image and the quantity of the item if > 1.
*/
export default function Slot({image, onClick, qty = 1}) {
return (
<div
className={styles.slot}
onClick={onClick}
>
<div
className={styles.slotInner}
style={image ? {backgroundImage: `url(${image})`} : {}}
>
{qty > 1 && (
<span
className={
[styles.qty, `q-${Math.round(Math.log10(qty))}`]
.filter(Boolean).join(' ')
}
>
{qty}
</span>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
.slot {
--size: 35px;
--base: calc(var(--size) / 5);
background-position: center;
background-repeat: no-repeat;
background-size: contain;
box-sizing: border-box;
display: inline-block;
image-rendering: pixelated;
padding: var(--base);
user-select: none;
}
.slotInner {
background-position: center;
background-repeat: no-repeat;
background-size: contain;
height: calc(var(--base) * 5);
position: relative;
width: calc(var(--base) * 5);
}
.qty {
bottom: calc(var(--base) / -1.25);
font-family: monospace;
font-size: calc(var(--base) * 2);
line-height: 1;
position: absolute;
right: calc(var(--base) / -1.25);
text-shadow:
0px -1px 0px white,
1px 0px 0px white,
0px 1px 0px white,
-1px 0px 0px white
;
&:global(.q-2) {
font-size: calc(var(--base) * 1.75);
}
&:global(.q-3) {
font-size: calc(var(--base) * 1.5);
}
&:global(.q-4) {
font-size: calc(var(--base) * 1.25);
}
}

30
src/components/ui.jsx Normal file
View File

@ -0,0 +1,30 @@
import {RESOLUTION} from '../constants';
import Dom from './dom';
import Pixi from './pixi';
import styles from './ui.module.css';
const ratio = RESOLUTION[0] / RESOLUTION[1];
export default function Ui() {
return (
<div className={styles.ui}>
<style>
{`
@media (max-aspect-ratio: ${ratio}) { .${styles.ui} { width: 100%; } }
@media (min-aspect-ratio: ${ratio}) { .${styles.ui} { height: 100%; } }
`}
</style>
<Pixi />
<Dom>
<div
style={{
backgroundColor: 'rgba(0, 0, 0, 0.1)',
height: '225px',
width: '400px',
}}
></div>
</Dom>
</div>
);
}

View File

@ -0,0 +1,3 @@
.ui {
position: relative;
}

4
src/constants.js Normal file
View File

@ -0,0 +1,4 @@
export const RESOLUTION = [
800,
450,
];

3
src/context/server.js Normal file
View File

@ -0,0 +1,3 @@
import {createContext} from 'react';
export default createContext();

16
src/index.css Normal file
View File

@ -0,0 +1,16 @@
html, body {
background-color: #333333;
box-sizing: border-box;
height: 100%;
line-height: 0;
margin: 0;
width: 100%;
}
.silphius {
align-items: center;
display: flex;
height: 100%;
justify-content: space-around;
width: 100%;
}

12
src/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="./index.css">
</head>
<body>
<script type="module" src="./client.js"></script>
<div class="silphius"></div>
</body>
</html>

24
src/server/local.js Normal file
View File

@ -0,0 +1,24 @@
export default class Local {
constructor() {
this.listeners = [];
this.worker = new Worker('/server/server.js');
this.worker.onmessage = (event) => {
for (const i in this.listeners) {
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) {
this.worker.postMessage(message);
}
}

54
src/server/server.js Normal file
View File

@ -0,0 +1,54 @@
const SPEED = 100;
const TPS = 60;
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': {
if (payload.type in MOVE_MAP) {
movement[MOVE_MAP[payload.type]] = payload.value;
}
break;
}
default:
}
};
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);

View File

@ -0,0 +1,52 @@
import {useEffect, useRef, useState} from 'react';
import Dom from '../components/dom';
import {RESOLUTION} from '../constants';
function Decorator({children, style}) {
const ref = useRef();
const [scale, setScale] = useState(0);
useEffect(() => {
if (!ref.current) {
return;
}
function onResize() {
const {parentNode} = ref.current;
const {width} = parentNode.getBoundingClientRect();
setScale(width / RESOLUTION[0]);
}
window.addEventListener('resize', onResize);
onResize();
return () => {
window.removeEventListener('resize', onResize);
}
}, [ref.current]);
return (
<div
ref={ref}
style={{
backgroundColor: '#1099bb',
opacity: 0 === scale ? 0 : 1,
position: 'relative',
height: `calc(${RESOLUTION[1]}px * ${scale})`,
width: '100%',
}}
>
<Dom>
<div style={style}>
{children}
</div>
</Dom>
</div>
);
}
export default function(options = {}) {
return function decorate(Decorating) {
return (
<Decorator style={options.style}>
<Decorating />
</Decorator>
);
};
}

View File

@ -0,0 +1,47 @@
import {useArgs} from '@storybook/preview-api';
import {fn} from '@storybook/test';
import {createElement} from 'react';
import potion from '../assets/potion.png';
import Hotbar from '../components/hotbar';
import Dom from '../components/dom';
import DomDecorator from './dom-decorator';
const slots = Array(10).fill({});
slots[2] = {image: potion, qty: 24};
export default {
title: 'Dom/Inventory/Hotbar',
component: Hotbar,
decorators: [
(Hotbar, ctx) => {
const [, updateArgs] = useArgs();
const {onActivate} = ctx.args;
ctx.args.onActivate = (i) => {
updateArgs({active: i});
if (onActivate) {
onActivate(i);
}
};
return Hotbar();
},
DomDecorator(),
],
tags: ['autodocs'],
args: {
active: 0,
onActivate: fn(),
slots,
},
argTypes: {
active: {
control: {
type: 'number',
min: 0,
max: 9,
}
},
},
};
export const Default = {};

View File

@ -0,0 +1,68 @@
import {createElement} from 'react';
import potion from '../assets/potion.png';
import Slot from '../components/slot';
import DomDecorator from './dom-decorator';
export default {
title: 'Dom/Inventory/Slot',
component: Slot,
decorators: [
DomDecorator({
style: {
border: '2px solid #999',
lineHeight: 0,
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
},
}),
],
tags: ['autodocs'],
argTypes: {
image: {
control: 'text',
},
qty: {
control: {
type: 'number',
min: 1,
max: 9999,
}
},
},
args: {
image: potion,
},
};
export const Single = {
args: {
qty: 1,
},
};
export const OneDigit = {
args: {
qty: 9,
},
};
export const TwoDigit = {
args: {
qty: 99,
},
};
export const ThreeDigit = {
args: {
qty: 999,
},
};
export const FourDigit = {
args: {
qty: 9999,
},
};

6
vite.config.js Normal file
View File

@ -0,0 +1,6 @@
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
});