Compare commits
45 Commits
9853edec44
...
6b60877711
Author | SHA1 | Date | |
---|---|---|---|
|
6b60877711 | ||
|
1b35b03eb1 | ||
|
2204c2cdbf | ||
|
066abec937 | ||
|
0639c825a9 | ||
|
3bbaf83140 | ||
|
85e252e423 | ||
|
0bfd4b3a9f | ||
|
9e82b3a5c1 | ||
|
0cdabd0858 | ||
|
fec717cbe1 | ||
|
1c88b32bad | ||
|
889498b243 | ||
|
9d9f94a7cc | ||
|
997ef691ca | ||
|
0cb5cdbe1f | ||
|
46cff307b1 | ||
|
e6f54c3694 | ||
|
f328ce4ccb | ||
|
b5019153f3 | ||
|
0588dbf6c2 | ||
|
da51111106 | ||
|
ffa391fcef | ||
|
c1bdae1c8c | ||
|
46f0a0cc07 | ||
|
92c85e57d3 | ||
|
493042b913 | ||
|
f673833a1d | ||
|
512c5a470a | ||
|
39e7082a28 | ||
|
3106788ca7 | ||
|
55f915d4a1 | ||
|
f1d3ad6a6d | ||
|
73b7a9e0a5 | ||
|
5492ec32bd | ||
|
9d176c2930 | ||
|
b20795137e | ||
|
749a3356c3 | ||
|
6a45622b9d | ||
|
e2c0ec7638 | ||
|
946a06e78a | ||
|
c24adc47a8 | ||
|
b137b91ced | ||
|
acaa930fe1 | ||
|
cd8a933a5b |
|
@ -61,6 +61,9 @@ module.exports = {
|
|||
},
|
||||
rules: {
|
||||
'react/prop-types': 'off',
|
||||
'jsx-a11y/label-has-associated-control': [2, {
|
||||
controlComponents: ['SliderText'],
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -71,7 +74,6 @@ module.exports = {
|
|||
'.eslintrc.cjs',
|
||||
'server.js',
|
||||
'vite.config.js',
|
||||
'public/assets/tileset.js',
|
||||
],
|
||||
env: {
|
||||
node: true,
|
||||
|
@ -81,7 +83,7 @@ module.exports = {
|
|||
// Assets
|
||||
{
|
||||
files: [
|
||||
'public/assets/**/*.js',
|
||||
'resources/**/*.js',
|
||||
],
|
||||
rules: {
|
||||
'no-undef': 0,
|
||||
|
|
|
@ -1,24 +1,14 @@
|
|||
import {LRUCache} from 'lru-cache';
|
||||
|
||||
import Components from '@/ecs/components/index.js';
|
||||
import Ecs from '@/ecs/ecs.js';
|
||||
import Systems from '@/ecs/systems/index.js';
|
||||
import {withResolvers} from '@/util/promise.js';
|
||||
|
||||
const cache = new LRUCache({
|
||||
max: 128,
|
||||
});
|
||||
import {readAsset} from '@/util/resources.js';
|
||||
|
||||
class PredictionEcs extends Ecs {
|
||||
async readAsset(uri) {
|
||||
if (!cache.has(uri)) {
|
||||
const {promise, resolve, reject} = withResolvers();
|
||||
cache.set(uri, promise);
|
||||
fetch(uri)
|
||||
.then((response) => resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0)))
|
||||
.catch(reject);
|
||||
}
|
||||
return cache.get(uri);
|
||||
async readAsset(path) {
|
||||
const resource = await readAsset(path);
|
||||
return resource
|
||||
? resource
|
||||
: new ArrayBuffer(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -49,7 +39,6 @@ function applyClientActions(elapsed) {
|
|||
}
|
||||
}
|
||||
}
|
||||
action.steps.push(elapsed);
|
||||
}
|
||||
if (1 === action.finished) {
|
||||
if (!Controlled.locked) {
|
||||
|
@ -65,20 +54,17 @@ function applyClientActions(elapsed) {
|
|||
}
|
||||
action.finished = 2;
|
||||
}
|
||||
if (!action.ack) {
|
||||
action.stop += elapsed;
|
||||
}
|
||||
if (action.ack && 2 === action.finished) {
|
||||
action.steps.shift();
|
||||
if (0 === action.steps.length) {
|
||||
action.start += elapsed;
|
||||
if (action.start >= action.stop) {
|
||||
finished.push(id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let leap = 0;
|
||||
for (const step of action.steps) {
|
||||
leap += step;
|
||||
}
|
||||
if (leap > 0) {
|
||||
ecs.predict(main, leap);
|
||||
}
|
||||
ecs.predict(main, action.stop - action.start);
|
||||
}
|
||||
for (const id of finished) {
|
||||
actions.delete(id);
|
||||
|
@ -108,10 +94,13 @@ onmessage = async (event) => {
|
|||
pending.delete(packet.payload.type);
|
||||
}
|
||||
else {
|
||||
const now = performance.now() / 1000;
|
||||
const tx = {
|
||||
action: packet.payload,
|
||||
ack: false,finished: 0,
|
||||
steps: [],
|
||||
ack: false,
|
||||
finished: 0,
|
||||
start: now,
|
||||
stop: now,
|
||||
};
|
||||
packet.payload.ack = Math.random();
|
||||
pending.set(packet.payload.type, packet.payload.ack);
|
||||
|
@ -152,9 +141,20 @@ onmessage = async (event) => {
|
|||
const authoritative = structuredClone(main.toNet(main));
|
||||
applyClientActions(packet.payload.elapsed);
|
||||
if (ecs.diff[mainEntityId]) {
|
||||
packet.payload.ecs[mainEntityId] = ecs.diff[mainEntityId];
|
||||
packet.payload.ecs[mainEntityId] ??= {};
|
||||
ecs.mergeDiff(
|
||||
packet.payload.ecs[mainEntityId],
|
||||
ecs.diff[mainEntityId],
|
||||
);
|
||||
const reset = {};
|
||||
for (const componentName in ecs.diff[mainEntityId]) {
|
||||
reset[componentName] = {};
|
||||
for (const property in ecs.diff[mainEntityId][componentName]) {
|
||||
reset[componentName][property] = authoritative[componentName][property];
|
||||
}
|
||||
}
|
||||
await ecs.apply({[mainEntityId]: reset});
|
||||
}
|
||||
await ecs.apply({[mainEntityId]: authoritative});
|
||||
}
|
||||
ecs.setClean();
|
||||
break;
|
||||
|
|
|
@ -44,7 +44,7 @@ export default class Alive extends Component {
|
|||
}
|
||||
static properties = {
|
||||
deathScript: {
|
||||
defaultValue: '/assets/misc/death-default.js',
|
||||
defaultValue: '/resources/misc/death-default.js',
|
||||
type: 'string',
|
||||
},
|
||||
health: {type: 'uint32'},
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import Component from '@/ecs/component.js';
|
||||
|
||||
import {distribute} from '@/util/inventory.js';
|
||||
|
||||
class ItemProxy {
|
||||
constructor(Component, instance, slot) {
|
||||
this.Component = Component;
|
||||
|
@ -94,24 +96,35 @@ class ItemProxy {
|
|||
get label() {
|
||||
return this.json.label;
|
||||
}
|
||||
get maximumStack() {
|
||||
return this.json.maximumStack ?? Infinity;
|
||||
}
|
||||
get projection() {
|
||||
return this.json.projection;
|
||||
}
|
||||
get price() {
|
||||
return this.json.price;
|
||||
}
|
||||
get qty() {
|
||||
return this.instance.slots[this.slot].qty;
|
||||
}
|
||||
set qty(qty) {
|
||||
const {instance} = this;
|
||||
if (qty <= 0) {
|
||||
this.Component.markChange(instance.entity, 'cleared', {[this.slot]: true});
|
||||
delete instance.slots[this.slot];
|
||||
delete instance.$$items[this.slot];
|
||||
if (qty === this.qty) {
|
||||
return;
|
||||
}
|
||||
else if (qty <= 0) {
|
||||
instance.clear(this.slot);
|
||||
}
|
||||
else {
|
||||
const difference = qty - instance.slots[this.slot].qty;
|
||||
instance.slots[this.slot].qty = qty;
|
||||
this.Component.markChange(instance.entity, 'qtyUpdated', {[this.slot]: qty});
|
||||
this.Component.markChange(instance.entity, 'qtyUpdated', {[this.slot]: difference});
|
||||
}
|
||||
}
|
||||
get source() {
|
||||
return this.instance.slots[this.slot].source;
|
||||
}
|
||||
}
|
||||
|
||||
export default class Inventory extends Component {
|
||||
|
@ -121,6 +134,7 @@ export default class Inventory extends Component {
|
|||
const {$$items, slots} = instance;
|
||||
if (cleared) {
|
||||
for (const slot in cleared) {
|
||||
delete $$items[slot];
|
||||
delete slots[slot];
|
||||
}
|
||||
}
|
||||
|
@ -133,7 +147,7 @@ export default class Inventory extends Component {
|
|||
}
|
||||
if (qtyUpdated) {
|
||||
for (const slot in qtyUpdated) {
|
||||
slots[slot].qty = qtyUpdated[slot];
|
||||
slots[slot].qty += qtyUpdated[slot];
|
||||
}
|
||||
}
|
||||
if (swapped) {
|
||||
|
@ -167,8 +181,77 @@ export default class Inventory extends Component {
|
|||
instanceFromSchema() {
|
||||
const Instance = super.instanceFromSchema();
|
||||
const Component = this;
|
||||
const {ecs} = Component;
|
||||
return class InventoryInstance extends Instance {
|
||||
$$items = {};
|
||||
clear(slot) {
|
||||
Component.markChange(this.entity, 'cleared', {[slot]: true});
|
||||
delete this.slots[slot];
|
||||
delete this.$$items[slot];
|
||||
}
|
||||
async distribute(slot, potentialDestinations) {
|
||||
const {slots} = this;
|
||||
if (!slots[slot]) {
|
||||
return;
|
||||
}
|
||||
const destinations = potentialDestinations
|
||||
.filter(([entityId, destination]) => {
|
||||
const {Inventory} = ecs.get(entityId);
|
||||
return (
|
||||
!Inventory.slots[destination]
|
||||
|| Inventory.slots[destination].source === slots[slot].source
|
||||
);
|
||||
});
|
||||
const {maximumStack, qty, source} = this.item(slot);
|
||||
const item = {
|
||||
maximumStack,
|
||||
qty,
|
||||
};
|
||||
const qtys = distribute(
|
||||
item,
|
||||
destinations.map(([entityId, destination]) => {
|
||||
const {Inventory} = ecs.get(entityId);
|
||||
if (entityId == Inventory.entity && destination === slot) {
|
||||
return 0;
|
||||
}
|
||||
return Inventory.slots[destination]
|
||||
? Inventory.slots[destination].qty
|
||||
: 0;
|
||||
}),
|
||||
);
|
||||
for (let i = 0; i < qtys.length; ++i) {
|
||||
const qty = qtys[i];
|
||||
const [entityId, destination] = destinations[i];
|
||||
const {Inventory} = ecs.get(entityId);
|
||||
// create new
|
||||
if (!Inventory.slots[destination]) {
|
||||
if (qty > 0) {
|
||||
const stack = {qty, source};
|
||||
Inventory.slots[destination] = {...stack};
|
||||
Inventory.$$items[destination] = new ItemProxy(Component, Inventory, destination);
|
||||
Component.markChange(entityId, 'given', {[destination]: {...stack}});
|
||||
await Inventory.$$items[destination].load(source);
|
||||
}
|
||||
}
|
||||
// update qty of existing
|
||||
else if (Inventory.slots[destination].qty !== qty) {
|
||||
Inventory.item(destination).qty = qty;
|
||||
}
|
||||
}
|
||||
// handle source separately if not also destination
|
||||
if (
|
||||
!destinations.find(([entityId, destination]) => {
|
||||
return entityId == this.entity && destination === slot;
|
||||
})
|
||||
) {
|
||||
if (0 === item.qty) {
|
||||
this.clear(slot);
|
||||
}
|
||||
else if (item.qty !== qty) {
|
||||
this.item(slot).qty = item.qty;
|
||||
}
|
||||
}
|
||||
}
|
||||
item(slot) {
|
||||
return this.$$items[slot];
|
||||
}
|
||||
|
@ -177,16 +260,16 @@ export default class Inventory extends Component {
|
|||
for (let slot = 1; slot < 11; ++slot) {
|
||||
if (slots[slot]?.source === stack.source) {
|
||||
slots[slot].qty += stack.qty;
|
||||
Component.markChange(this.entity, 'qtyUpdated', {[slot]: slots[slot].qty});
|
||||
Component.markChange(this.entity, 'qtyUpdated', {[slot]: stack.qty});
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let slot = 1; slot < 11; ++slot) {
|
||||
if (!slots[slot]) {
|
||||
slots[slot] = stack;
|
||||
slots[slot] = {...stack};
|
||||
this.$$items[slot] = new ItemProxy(Component, this, slot);
|
||||
await this.$$items[slot].load();
|
||||
Component.markChange(this.entity, 'given', {[slot]: slots[slot]});
|
||||
Component.markChange(this.entity, 'given', {[slot]: {...stack}});
|
||||
await this.$$items[slot].load(stack.source);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -200,17 +283,25 @@ export default class Inventory extends Component {
|
|||
if (undefined === slots[l]) {
|
||||
delete slots[l];
|
||||
}
|
||||
else {
|
||||
$$items[l].slot = l;
|
||||
}
|
||||
if (undefined === otherSlots[r]) {
|
||||
delete otherSlots[r];
|
||||
}
|
||||
else {
|
||||
$$items[r].slot = r;
|
||||
}
|
||||
Component.markChange(this.entity, 'swapped', [[l, OtherInventory.entity, r]]);
|
||||
if (this.entity !== OtherInventory.entity) {
|
||||
Component.markChange(OtherInventory.entity, 'swapped', [[r, this.entity, l]]);
|
||||
}
|
||||
}
|
||||
toNet(recipient, data) {
|
||||
if (recipient.id !== this.entity && this !== recipient.Player.openInventory) {
|
||||
return {};
|
||||
}
|
||||
return super.toNet(data);
|
||||
return super.toNet(recipient, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -223,10 +314,6 @@ export default class Inventory extends Component {
|
|||
mergeDiff(original, update) {
|
||||
const merged = {
|
||||
...original,
|
||||
qtyUpdated: {
|
||||
...original.qtyUpdated,
|
||||
...update.qtyUpdated,
|
||||
},
|
||||
cleared: {
|
||||
...original.cleared,
|
||||
...update.cleared,
|
||||
|
@ -240,6 +327,14 @@ export default class Inventory extends Component {
|
|||
...(update.swapped || []),
|
||||
],
|
||||
};
|
||||
if (update.qtyUpdated) {
|
||||
if (!merged.qtyUpdated) {
|
||||
merged.qtyUpdated = {};
|
||||
}
|
||||
for (const slot in update.qtyUpdated) {
|
||||
merged.qtyUpdated[slot] = (merged.qtyUpdated[slot] ?? 0) + update.qtyUpdated[slot];
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
static properties = {
|
||||
|
@ -248,7 +343,7 @@ export default class Inventory extends Component {
|
|||
value: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
quantity: {type: 'uint16'},
|
||||
qty: {type: 'uint16'},
|
||||
source: {type: 'string'},
|
||||
},
|
||||
},
|
||||
|
|
3
app/ecs/components/shop.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Component from '@/ecs/component.js';
|
||||
|
||||
export default class Shop extends Component {}
|
|
@ -29,6 +29,7 @@ export default class Vulnerable extends Component {
|
|||
const {Alive} = Component.ecs.get(this.entity);
|
||||
if (Alive) {
|
||||
switch (specification.type) {
|
||||
case DamageTypes.HEALING:
|
||||
case DamageTypes.PAIN: {
|
||||
Alive.acceptDamage(specification.amount);
|
||||
}
|
||||
|
|
7
app/ecs/components/wallet.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import Component from '@/ecs/component.js';
|
||||
|
||||
export default class Wallet extends Component {
|
||||
static properties = {
|
||||
gold: {type: 'uint32'},
|
||||
};
|
||||
}
|
|
@ -52,7 +52,7 @@ export default class Ecs {
|
|||
promises.add(promise);
|
||||
promise.then(() => {
|
||||
promises.delete(promise);
|
||||
if (!this.$$destructionDependencies.get(id)?.resolvers) {
|
||||
if (0 === promises.size && !this.$$destructionDependencies.get(id)?.resolvers) {
|
||||
this.$$destructionDependencies.delete(id);
|
||||
}
|
||||
});
|
||||
|
@ -397,16 +397,20 @@ export default class Ecs {
|
|||
}
|
||||
// Otherwise, merge.
|
||||
else {
|
||||
for (const componentName in components) {
|
||||
this.diff[entityId][componentName] = false === components[componentName]
|
||||
this.mergeDiff(this.diff[entityId], components);
|
||||
}
|
||||
}
|
||||
|
||||
mergeDiff(l, r) {
|
||||
for (const componentName in r) {
|
||||
l[componentName] = false === r[componentName]
|
||||
? false
|
||||
: this.Components[componentName].mergeDiff(
|
||||
this.diff[entityId][componentName] || {},
|
||||
components[componentName],
|
||||
l[componentName] || {},
|
||||
r[componentName],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
predict(entity, elapsed) {
|
||||
for (const systemName in this.Systems) {
|
||||
|
|
|
@ -13,15 +13,9 @@ import { renderToPipeableStream } from "react-dom/server";
|
|||
|
||||
const ABORT_DELAY = 5_000;
|
||||
|
||||
export async function websocket(server, viteDevServer) {
|
||||
if (viteDevServer) {
|
||||
const {createViteRuntime} = await import('vite');
|
||||
const runtime = await createViteRuntime(viteDevServer);
|
||||
(await runtime.executeEntrypoint('/app/server/websocket.js')).default(server);
|
||||
}
|
||||
else {
|
||||
(await import('./server/websocket.js')).default(server);
|
||||
}
|
||||
export async function handleUpgrade(request, socket, head) {
|
||||
const {handleUpgrade} = await import('./server/websocket.js');
|
||||
handleUpgrade(request, socket, head);
|
||||
}
|
||||
|
||||
export default function handleRequest(
|
||||
|
@ -120,6 +114,11 @@ function handleBrowserRequest(
|
|||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
// security
|
||||
// responseHeaders.set("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
// responseHeaders.set("Cross-Origin-Opener-Policy", "same-origin");
|
||||
// responseHeaders.set("Cross-Origin-Resource-Policy", "same-site");
|
||||
// responseHeaders.set("Content-Security-Policy", "default-src 'self'");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import {LRUCache} from 'lru-cache';
|
||||
|
||||
import Ecs from '@/ecs/ecs.js';
|
||||
import {withResolvers} from '@/util/promise.js';
|
||||
|
||||
const cache = new LRUCache({
|
||||
max: 128,
|
||||
});
|
||||
import {readAsset} from '@/util/resources.js';
|
||||
|
||||
export default class ClientEcs extends Ecs {
|
||||
constructor(specification) {
|
||||
|
@ -19,16 +13,10 @@ export default class ClientEcs extends Ecs {
|
|||
}
|
||||
});
|
||||
}
|
||||
async readAsset(uri) {
|
||||
if (!cache.has(uri)) {
|
||||
const {promise, resolve, reject} = withResolvers();
|
||||
cache.set(uri, promise);
|
||||
fetch(new URL(uri, location.origin))
|
||||
.then(async (response) => {
|
||||
resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0));
|
||||
})
|
||||
.catch(reject);
|
||||
}
|
||||
return cache.get(uri);
|
||||
async readAsset(path) {
|
||||
const resource = await readAsset(path);
|
||||
return resource
|
||||
? resource
|
||||
: new ArrayBuffer(0);
|
||||
}
|
||||
}
|
||||
|
|
43
app/react/components/dev/slider-text.jsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import {useState} from 'react';
|
||||
|
||||
function SliderText({
|
||||
defaultValue,
|
||||
max,
|
||||
min,
|
||||
name,
|
||||
onChange,
|
||||
step,
|
||||
}) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
value={value}
|
||||
max={max}
|
||||
min={min}
|
||||
name={name}
|
||||
onChange={({currentTarget: {value}}) => {
|
||||
setValue(value);
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
step={step}
|
||||
type="range"
|
||||
/>
|
||||
<input
|
||||
value={value}
|
||||
name={name}
|
||||
onChange={({currentTarget: {value}}) => {
|
||||
setValue(value);
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SliderText;
|
|
@ -1,6 +1,7 @@
|
|||
import {memo} from 'react';
|
||||
|
||||
import styles from './bag.module.css';
|
||||
import gridStyles from './grid.module.css';
|
||||
|
||||
import Grid from './grid.jsx';
|
||||
|
||||
|
@ -8,8 +9,9 @@ import Grid from './grid.jsx';
|
|||
* Inventory bag. 10-40 slots of inventory.
|
||||
*/
|
||||
function Bag({
|
||||
highlighted,
|
||||
isInventoryOpen,
|
||||
onActivate,
|
||||
onSlotMouseDown,
|
||||
slots,
|
||||
}) {
|
||||
return (
|
||||
|
@ -17,11 +19,20 @@ function Bag({
|
|||
className={styles.bag}
|
||||
style={isInventoryOpen ? {transition: 'opacity 50ms'} : {opacity: 0, left: '-440px'}}
|
||||
>
|
||||
<style>{`
|
||||
.${styles.bag} .${gridStyles.highlighted} {
|
||||
background-color: rgba(255, 0, 0, 0.4);
|
||||
}
|
||||
.${styles.bag} .${gridStyles.highlighted} button {
|
||||
border: 2.5px dashed rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
`}</style>
|
||||
<Grid
|
||||
color="rgba(02, 02, 28, 0.6)"
|
||||
columns={10}
|
||||
highlighted={highlighted}
|
||||
label="Bag"
|
||||
onActivate={onActivate}
|
||||
onSlotMouseDown={onSlotMouseDown}
|
||||
slots={slots}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -28,7 +28,6 @@ function damageHue(type) {
|
|||
|
||||
class Damage {
|
||||
elapsed = 0;
|
||||
hue = [0, 30];
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
step = 0;
|
||||
|
@ -111,7 +110,7 @@ class Damage {
|
|||
easeInOutExpo(
|
||||
Math.abs((this.elapsed % 0.3) - 0.15) / 0.15,
|
||||
this.hueStart,
|
||||
this.hueEnd,
|
||||
this.hueEnd - this.hueStart,
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
|
40
app/react/components/dom/date.jsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import {useState} from 'react';
|
||||
|
||||
import styles from './date.module.css';
|
||||
|
||||
export function Date({season, date}) {
|
||||
const seasonLabel = (() => {
|
||||
switch (season) {
|
||||
case 0: return 'Spring';
|
||||
case 1: return 'Summer';
|
||||
case 2: return 'Autumn';
|
||||
case 3: return 'Winter';
|
||||
}
|
||||
})();
|
||||
const dateLabel = (() => {
|
||||
if (date >= 10 && date < 13) {
|
||||
return <><span className={styles.number}>{date + 1}</span>th</>;
|
||||
}
|
||||
switch (date % 10) {
|
||||
case 0: return <><span className={styles.number}>{date + 1}</span>st</>;
|
||||
case 1: return <><span className={styles.number}>{date + 1}</span>nd</>;
|
||||
case 2: return <><span className={styles.number}>{date + 1}</span>rd</>;
|
||||
default: return <><span className={styles.number}>{date + 1}</span>th</>;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className={styles.date}>
|
||||
{seasonLabel}
|
||||
|
||||
{dateLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectedDate() {
|
||||
const [season, setSeason] = useState(0);
|
||||
const [date, setDate] = useState(0);
|
||||
return <Date season={season} date={date} />;
|
||||
}
|
||||
|
||||
export default ConnectedDate;
|
11
app/react/components/dom/date.module.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-family: Cookbook, Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: 16px;
|
||||
text-shadow: 1px 0 0 black, 0 1px 0 black, -1px 0 0 black, 0 -1px 0 black;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
15
app/react/components/dom/datetime.jsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import Date from './date.jsx';
|
||||
import Time from './time.jsx';
|
||||
|
||||
import styles from './datetime.module.css';
|
||||
|
||||
function DateTime() {
|
||||
return (
|
||||
<div className={styles.datetime}>
|
||||
<Date />
|
||||
<Time />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateTime;
|
12
app/react/components/dom/datetime.module.css
Normal file
|
@ -0,0 +1,12 @@
|
|||
.datetime {
|
||||
align-items: center;
|
||||
color: white;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 4px;
|
||||
div:first-child:not(:last-child):after {
|
||||
content: ', ';
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ import {useCallback, useState} from 'react';
|
|||
|
||||
import {usePacket} from '@/react/context/client.js';
|
||||
import {useEcsTick} from '@/react/context/ecs.js';
|
||||
import {RESOLUTION} from '@/util/constants.js';
|
||||
import {parseLetters} from '@/util/dialogue.js';
|
||||
|
||||
import Damages from './damages.jsx';
|
||||
|
@ -14,6 +15,10 @@ export default function Entities({
|
|||
setChatMessages,
|
||||
}) {
|
||||
const [entities, setEntities] = useState({});
|
||||
const translatedCamera = {
|
||||
x: Math.round((camera.x * scale) - RESOLUTION.x / 2),
|
||||
y: Math.round((camera.y * scale) - RESOLUTION.y / 2),
|
||||
};
|
||||
usePacket('EcsChange', async () => {
|
||||
setEntities({});
|
||||
});
|
||||
|
@ -98,7 +103,7 @@ export default function Entities({
|
|||
for (const id in entities) {
|
||||
renderables.push(
|
||||
<Entity
|
||||
camera={camera}
|
||||
camera={translatedCamera}
|
||||
entity={entities[id]}
|
||||
key={id}
|
||||
scale={scale}
|
||||
|
@ -109,8 +114,8 @@ export default function Entities({
|
|||
<div
|
||||
style={{
|
||||
translate: `
|
||||
calc(-1px * ${camera.x})
|
||||
calc(-1px * ${camera.y})
|
||||
calc(-1px * ${translatedCamera.x})
|
||||
calc(-1px * ${translatedCamera.y})
|
||||
`,
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import {memo} from 'react';
|
||||
|
||||
import styles from './external.module.css';
|
||||
import gridStyles from './grid.module.css';
|
||||
|
||||
import Grid from './grid.jsx';
|
||||
|
||||
/**
|
||||
* External inventory.
|
||||
*/
|
||||
export default function External({
|
||||
function External({
|
||||
highlighted,
|
||||
isInventoryOpen,
|
||||
onActivate,
|
||||
onSlotMouseDown,
|
||||
slots,
|
||||
}) {
|
||||
return (
|
||||
|
@ -15,13 +19,24 @@ export default function External({
|
|||
className={styles.external}
|
||||
style={isInventoryOpen ? {transition: 'opacity 50ms'} : {opacity: 0, top: '450px'}}
|
||||
>
|
||||
<style>{`
|
||||
.${styles.external} .${gridStyles.highlighted} {
|
||||
background-color: rgba(0, 255, 0, 0.4);
|
||||
}
|
||||
.${styles.external} .${gridStyles.highlighted} button {
|
||||
border: 2.5px dashed rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
`}</style>
|
||||
<Grid
|
||||
color="rgba(57, 02, 02, 0.6)"
|
||||
columns={10}
|
||||
highlighted={highlighted}
|
||||
label="Chest"
|
||||
onActivate={onActivate}
|
||||
onSlotMouseDown={onSlotMouseDown}
|
||||
slots={slots}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(External);
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
.external {
|
||||
left: 20px;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
top: 274px;
|
||||
transition: top 150ms, opacity 200ms;
|
||||
--nothing: 0;
|
||||
}
|
||||
|
|
|
@ -8,14 +8,21 @@ export default function Grid({
|
|||
active = -1,
|
||||
color,
|
||||
columns,
|
||||
highlighted,
|
||||
label,
|
||||
onActivate,
|
||||
onSlotMouseDown,
|
||||
onSlotMouseMove,
|
||||
onSlotMouseUp,
|
||||
slots,
|
||||
}) {
|
||||
const Slots = slots.map((slot, i) => (
|
||||
<div
|
||||
className={
|
||||
[styles.slot, active === i && styles.active]
|
||||
[
|
||||
styles.slot,
|
||||
active === i && styles.active,
|
||||
highlighted && highlighted.includes(i) && styles.highlighted,
|
||||
]
|
||||
.filter(Boolean).join(' ')
|
||||
}
|
||||
key={i}
|
||||
|
@ -23,26 +30,21 @@ export default function Grid({
|
|||
<Slot
|
||||
icon={slot?.icon}
|
||||
onMouseDown={(event) => {
|
||||
onActivate(i)
|
||||
onSlotMouseDown?.(i, event)
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseMove={(event) => {
|
||||
onSlotMouseMove?.(i, event)
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
onSlotMouseUp?.(i, event)
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
if (!slot) {
|
||||
event.preventDefault();
|
||||
}
|
||||
event.dataTransfer.setData('silphius/item', i);
|
||||
onActivate(i);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
onActivate(i);
|
||||
}}
|
||||
temporary={slot?.temporary}
|
||||
qty={slot?.qty}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -43,3 +43,7 @@
|
|||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
--nothing: 0;
|
||||
}
|
||||
|
|
|
@ -11,8 +11,8 @@ import Grid from './grid.jsx';
|
|||
function Hotbar({
|
||||
active,
|
||||
hotbarIsHidden,
|
||||
onActivate,
|
||||
slots,
|
||||
...rest
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
|
@ -23,14 +23,20 @@ function Hotbar({
|
|||
.${styles.hotbar} .${gridStyles.label} {
|
||||
text-align: center;
|
||||
}
|
||||
.${styles.hotbar} .${gridStyles.highlighted} {
|
||||
background-color: rgba(255, 0, 0, 0.4);
|
||||
}
|
||||
.${styles.hotbar} .${gridStyles.highlighted} button {
|
||||
border: 2.5px dashed rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
`}</style>
|
||||
<Grid
|
||||
active={active}
|
||||
color="rgba(02, 02, 57, 0.6)"
|
||||
columns={10}
|
||||
label={slots[active] && slots[active].label}
|
||||
onActivate={onActivate}
|
||||
slots={slots}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -11,7 +11,9 @@ export default function Slot({
|
|||
onDragStart,
|
||||
onDrop,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
temporary = false,
|
||||
qty = 1,
|
||||
}) {
|
||||
return (
|
||||
|
@ -27,6 +29,7 @@ export default function Slot({
|
|||
event.preventDefault();
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
>
|
||||
<div
|
||||
|
@ -36,7 +39,11 @@ export default function Slot({
|
|||
{qty > 1 && (
|
||||
<span
|
||||
className={
|
||||
[styles.qty, `q-${Math.round(Math.log10(qty))}`]
|
||||
[
|
||||
styles.qty,
|
||||
temporary && styles.temporary,
|
||||
`q-${Math.floor(Math.log10(qty))}`,
|
||||
]
|
||||
.filter(Boolean).join(' ')
|
||||
}
|
||||
>
|
||||
|
|
|
@ -28,24 +28,34 @@
|
|||
|
||||
.qty {
|
||||
bottom: calc(var(--space) * 0.125);
|
||||
font-family: monospace;
|
||||
color: white;
|
||||
font-family: joystix;
|
||||
font-size: calc(var(--space) * 2);
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
right: calc(var(--space) * -0.25);
|
||||
right: calc(var(--space) * -0.5);
|
||||
text-shadow:
|
||||
0px -1px 0px white,
|
||||
1px 0px 0px white,
|
||||
0px 1px 0px white,
|
||||
-1px 0px 0px white
|
||||
0px -1px 0px black,
|
||||
1px 0px 0px black,
|
||||
0px 1px 0px black,
|
||||
-1px 0px 0px black
|
||||
;
|
||||
&:global(.q-2) {
|
||||
font-size: calc(var(--space) * 1.75);
|
||||
}
|
||||
&:global(.q-3) {
|
||||
font-size: calc(var(--space) * 1.5);
|
||||
}
|
||||
&:global(.q-4) {
|
||||
&:global(.q-3) {
|
||||
font-size: calc(var(--space) * 1.25);
|
||||
}
|
||||
&:global(.q-4) {
|
||||
font-size: calc(var(--space) * 1.125);
|
||||
}
|
||||
&.temporary {
|
||||
color: gold;
|
||||
text-shadow:
|
||||
0px -1px 0px black,
|
||||
1px 0px 0px black,
|
||||
0px 1px 0px black,
|
||||
-1px 0px 0px black
|
||||
;
|
||||
}
|
||||
}
|
||||
|
|
43
app/react/components/dom/time.jsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import {memo, useCallback, useState} from 'react';
|
||||
|
||||
import {useEcsTick} from '@/react/context/ecs.js';
|
||||
|
||||
import styles from './time.module.css';
|
||||
|
||||
export function Time({hour, minute}) {
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {timeStyle: 'short'});
|
||||
const {hour12} = formatter.resolvedOptions();
|
||||
const formatted = formatter.format(new Date(2012, 11, 20, hour, minute));
|
||||
return (
|
||||
<div className={styles.time}>
|
||||
{
|
||||
hour12
|
||||
? <>{formatted.slice(0, -3)}<span className={styles.meridian}>{formatted.slice(-2)}</span></>
|
||||
: formatted
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoTime = memo(Time);
|
||||
|
||||
function ConnectedTime() {
|
||||
const [hour, setHour] = useState(-1);
|
||||
const [minute, setMinute] = useState(0);
|
||||
useEcsTick(useCallback((payload, ecs) => {
|
||||
const master = ecs.get(1);
|
||||
if (!master) {
|
||||
return;
|
||||
}
|
||||
const {Time: {hour}} = ecs.get(1);
|
||||
const wholeHour = Math.floor(hour);
|
||||
setHour(wholeHour);
|
||||
setMinute(Math.floor(60 * (hour - wholeHour)));
|
||||
}, []));
|
||||
if (-1 === hour) {
|
||||
return false;
|
||||
}
|
||||
return <MemoTime hour={hour} minute={minute} />;
|
||||
}
|
||||
|
||||
export default ConnectedTime;
|
14
app/react/components/dom/time.module.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
.time {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
text-shadow: 1px 0 0 black, 0 1px 0 black, -1px 0 0 black, 0 -1px 0 black;
|
||||
}
|
||||
|
||||
.meridian {
|
||||
align-self: self-end;
|
||||
font-family: Cookbook, Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: 10px;
|
||||
padding-left: 2px;
|
||||
}
|
86
app/react/components/dom/trade.jsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import styles from './trade.module.css';
|
||||
|
||||
function reducePrice(r, item) {
|
||||
return r + item.price * item.qty;
|
||||
}
|
||||
|
||||
function Trade({
|
||||
isInventoryOpen,
|
||||
losing: losingUnfiltered,
|
||||
onTradeAccepted,
|
||||
gaining: gainingUnfiltered,
|
||||
wallet,
|
||||
}) {
|
||||
const losing = losingUnfiltered.filter(Boolean);
|
||||
const gaining = gainingUnfiltered.filter(Boolean);
|
||||
const nop = 0 === losing.length && 0 === gaining.length;
|
||||
let earn = losing.reduce(reducePrice, 0) - gaining.reduce(reducePrice, 0);
|
||||
const canAfford = -earn <= wallet;
|
||||
return (
|
||||
<div
|
||||
className={styles.trade}
|
||||
style={isInventoryOpen ? {transition: 'opacity 50ms'} : {opacity: 0, top: '450px'}}
|
||||
>
|
||||
<div className={styles.tradeInner}>
|
||||
{
|
||||
nop
|
||||
? (
|
||||
<div className={styles.nop}>Let's trade!</div>
|
||||
)
|
||||
: (
|
||||
<ul className={styles.summary}>
|
||||
{losing.length > 0 && (
|
||||
<li className={styles.lose}>
|
||||
Shop receives:
|
||||
{' '}
|
||||
<ul className={styles.items}>
|
||||
{losing.map(({label, qty}, i) => (
|
||||
<li key={i}>{label} x<span className={styles.qty}>{qty}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
{gaining.length > 0 && (
|
||||
<li className={styles.gain}>
|
||||
You receive:
|
||||
{' '}
|
||||
<ul className={styles.items}>
|
||||
{gaining.map(({label, qty}, i) => (
|
||||
<li key={i}>{label} x<span className={styles.qty}>{qty}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
<li className={styles.subtotal}>
|
||||
{
|
||||
earn > 0
|
||||
? (
|
||||
<span className={styles.cost}>You <span className={styles.make}>earn <span className={styles.amount}>{earn}</span></span><span className={styles.gold}>🄶</span></span>
|
||||
)
|
||||
: (
|
||||
earn < 0
|
||||
? <span className={styles.cost}>You <span className={styles.pay}>pay <span className={styles.amount}>{-earn}</span></span><span className={styles.gold}>🄶</span></span>
|
||||
: false
|
||||
)
|
||||
}
|
||||
<button
|
||||
className={styles.agree}
|
||||
disabled={!canAfford}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onTradeAccepted(event);
|
||||
}}
|
||||
title={canAfford ? "Finish the transaction" : `Transaction requires ${-earn}g but you only have ${wallet}g`}
|
||||
>
|
||||
{canAfford ? "Agree" : "Can't afford"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Trade;
|
129
app/react/components/dom/trade.module.css
Normal file
|
@ -0,0 +1,129 @@
|
|||
.trade {
|
||||
border: 2.5px solid #444444;
|
||||
flex-grow: 1;
|
||||
font-size: 0.8em;
|
||||
margin: 16px 40px 0 8px;
|
||||
text-shadow:
|
||||
1px 0 0 black,
|
||||
0 1px 0 black,
|
||||
-1px 0 0 black,
|
||||
0 -1px 0 black
|
||||
;
|
||||
}
|
||||
|
||||
.tradeInner {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border: 2.5px solid #999999;
|
||||
display: flex;
|
||||
font-family: Cookbook, Georgia, 'Times New Roman', Times, serif;
|
||||
height: 100%;
|
||||
padding: 2.5px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.agree {
|
||||
background-color: rgba(255, 90, 0, 1);
|
||||
border: 2.5px solid #cccccc;
|
||||
box-shadow: inset 0 0 10px #666666;
|
||||
color: white;
|
||||
font-family: Cookbook, Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: 1em;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
bottom: 2.5px;
|
||||
right: 2.5px;
|
||||
text-shadow:
|
||||
1px 0 0 black,
|
||||
0 1px 0 black,
|
||||
-1px 0 0 black,
|
||||
0 -1px 0 black
|
||||
;
|
||||
transition: box-shadow 200ms;
|
||||
&:disabled {
|
||||
background-color: #333333;
|
||||
border-color: #777777;
|
||||
color: #777777;
|
||||
}
|
||||
&:not(:disabled):hover {
|
||||
box-shadow: inset 0 0 10px #222222;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5px;
|
||||
width: 100%;
|
||||
> li {
|
||||
padding: 2.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.gain {
|
||||
background-color: rgba(0, 255, 0, 0.5);
|
||||
border: 2.5px solid rgba(0, 255, 0, 0.5);
|
||||
max-height: 40.5px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lose {
|
||||
background-color: rgba(255, 0, 0, 0.5);
|
||||
border: 2.5px solid rgba(255, 0, 0, 0.5);
|
||||
max-height: 40.5px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: inline;
|
||||
li {
|
||||
display: inline;
|
||||
&:not(:last-child):after {
|
||||
content: ', ';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.make {
|
||||
color: rgb(0, 255, 0);
|
||||
}
|
||||
|
||||
.pay {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.gold {
|
||||
color: gold;
|
||||
}
|
||||
|
||||
.qty {
|
||||
font-family: Joystix;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.cost {
|
||||
position: absolute;
|
||||
bottom: 2.5px;
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 1.5em;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.nop {
|
||||
align-self: center;
|
||||
font-size: 2em;
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
17
app/react/components/dom/wallet.jsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import styles from './wallet.module.css';
|
||||
|
||||
function Wallet({gold}) {
|
||||
const goldString = gold.toString();
|
||||
const pad = ''.padStart(7 - goldString.length, '0');
|
||||
return (
|
||||
<span className={styles.wallet}>
|
||||
<span className={styles.number}>
|
||||
<span className={styles.pad}>{pad}</span>
|
||||
<span className={styles.amount}>{goldString}</span>
|
||||
</span>
|
||||
<span className={styles.gold}>🄶</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default Wallet;
|
19
app/react/components/dom/wallet.module.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
.wallet {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
text-shadow: 1px 0 0 black, 0 1px 0 black, -1px 0 0 black, 0 -1px 0 black;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.gold {
|
||||
color: gold;
|
||||
font-family: Cookbook, Georgia, 'Times New Roman', Times, serif;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-family: Joystix;
|
||||
}
|
||||
|
||||
.pad {
|
||||
color: #555555;
|
||||
}
|
|
@ -10,7 +10,7 @@ import TargetingGrid from './targeting-grid.jsx';
|
|||
import TileLayer from './tile-layer.jsx';
|
||||
import Water from './water.jsx';
|
||||
|
||||
export default function Ecs({camera, monopolizers, particleWorker, scale}) {
|
||||
export default function Ecs({monopolizers, particleWorker}) {
|
||||
const app = useApp();
|
||||
const mainEntityRef = useMainEntity();
|
||||
const [layers, setLayers] = useState([]);
|
||||
|
@ -120,17 +120,22 @@ export default function Ecs({camera, monopolizers, particleWorker, scale}) {
|
|||
}
|
||||
if (entity) {
|
||||
const {Direction, Position, Wielder} = entity;
|
||||
setPosition(Position.toJSON());
|
||||
setProjected(Wielder.activeItem()?.project(Position.tile, Direction.quantize(4)));
|
||||
setPosition((position) => {
|
||||
return position.x !== Position.x || position.y !== Position.y
|
||||
? Position.toJSON()
|
||||
: position;
|
||||
});
|
||||
setProjected((projected) => {
|
||||
const newProjected = Wielder.activeItem()?.project(Position.tile, Direction.quantize(4));
|
||||
return JSON.stringify(projected) !== JSON.stringify(newProjected)
|
||||
? newProjected
|
||||
: projected;
|
||||
});
|
||||
}
|
||||
}, [app.ambientLight, mainEntityRef]);
|
||||
useEcsTick(onEcsTick);
|
||||
return (
|
||||
<Container
|
||||
scale={scale}
|
||||
x={-camera.x}
|
||||
y={-camera.y}
|
||||
>
|
||||
<>
|
||||
<Container>
|
||||
{layers.map((layer, i) => (
|
||||
<TileLayer
|
||||
|
@ -162,6 +167,6 @@ export default function Ecs({camera, monopolizers, particleWorker, scale}) {
|
|||
monopolizers={monopolizers}
|
||||
particleWorker={particleWorker}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -54,6 +54,8 @@ export default class Entity {
|
|||
this.debug.removeChild(this.interactionAabb);
|
||||
}
|
||||
this.interactionAabb = undefined;
|
||||
this.diffuse.rotation = 0;
|
||||
this.normals.rotation = 0;
|
||||
}
|
||||
setDebug(isDebugging) {
|
||||
if (isDebugging) {
|
||||
|
@ -141,7 +143,9 @@ export default class Entity {
|
|||
this.entity.Sprite.animation,
|
||||
this.entity.Sprite.frame,
|
||||
);
|
||||
if (diffuse.texture !== texture) {
|
||||
diffuse.texture = texture;
|
||||
}
|
||||
if (asset.data.meta.normals) {
|
||||
const {pathname} = new URL(
|
||||
Sprite.source
|
||||
|
@ -157,17 +161,14 @@ export default class Entity {
|
|||
this.entity.Sprite.animation,
|
||||
this.entity.Sprite.frame,
|
||||
);
|
||||
if (normals.texture !== texture) {
|
||||
normals.texture = texture;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!this.attached) {
|
||||
const {diffuse, normals} = this;
|
||||
diffuse.rotation = 0;
|
||||
normals.rotation = 0;
|
||||
}
|
||||
if (Direction) {
|
||||
const {diffuse, normals} = this;
|
||||
if (!this.attached || 'direction' in Direction) {
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import {SCALE_MODES} from '@pixi/constants';
|
||||
import {BaseTexture, extensions} from '@pixi/core';
|
||||
import {Stage as PixiStage} from '@pixi/react';
|
||||
import {createElement, useContext} from 'react';
|
||||
import {Container, Stage as PixiStage} from '@pixi/react';
|
||||
import {createElement, forwardRef, memo, useContext} from 'react';
|
||||
|
||||
import AssetsContext from '@/react/context/assets.js';
|
||||
import ClientContext from '@/react/context/client.js';
|
||||
import DebugContext from '@/react/context/debug.js';
|
||||
import EcsContext from '@/react/context/ecs.js';
|
||||
import MainEntityContext from '@/react/context/main-entity.js';
|
||||
import RadiansContext from '@/react/context/radians.js';
|
||||
import {RESOLUTION} from '@/util/constants.js';
|
||||
|
||||
import Ecs from './ecs.jsx';
|
||||
|
@ -28,7 +27,6 @@ const Contexts = [
|
|||
DebugContext,
|
||||
EcsContext,
|
||||
MainEntityContext,
|
||||
RadiansContext,
|
||||
];
|
||||
|
||||
const ContextBridge = ({children, render}) => {
|
||||
|
@ -57,7 +55,7 @@ export const Stage = ({children, ...props}) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default function Pixi({camera, monopolizers, particleWorker, scale}) {
|
||||
function Pixi({monopolizers, particleWorker}, ref) {
|
||||
return (
|
||||
<Stage
|
||||
className={styles.stage}
|
||||
|
@ -66,14 +64,17 @@ export default function Pixi({camera, monopolizers, particleWorker, scale}) {
|
|||
options={{
|
||||
background: 0x0,
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
ref={ref}
|
||||
>
|
||||
<Ecs
|
||||
camera={camera}
|
||||
monopolizers={monopolizers}
|
||||
particleWorker={particleWorker}
|
||||
scale={scale}
|
||||
/>
|
||||
</Container>
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(forwardRef(Pixi));
|
||||
|
|
|
@ -18,7 +18,7 @@ const TileLayerInternal = PixiComponent('TileLayer', {
|
|||
},
|
||||
applyProps: (container, {tileLayer: oldTileLayer}, props) => {
|
||||
const {asset, group, renderer, tileLayer} = props;
|
||||
const extless = tileLayer.source.slice('/assets/'.length, -'.json'.length);
|
||||
const extless = tileLayer.source.slice('/resources/'.length, -'.json'.length);
|
||||
const {textures} = asset;
|
||||
if (tileLayer === oldTileLayer) {
|
||||
return;
|
||||
|
@ -134,14 +134,14 @@ export default function TileLayer(props) {
|
|||
<>
|
||||
<TileLayerInternal
|
||||
{...props}
|
||||
asset={asset}
|
||||
group={deferredLighting.diffuseGroup}
|
||||
asset={normalsAsset}
|
||||
group={deferredLighting.normalGroup}
|
||||
renderer={renderer}
|
||||
/>
|
||||
<TileLayerInternal
|
||||
{...props}
|
||||
asset={normalsAsset}
|
||||
group={deferredLighting.normalGroup}
|
||||
asset={asset}
|
||||
group={deferredLighting.diffuseGroup}
|
||||
renderer={renderer}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -6,15 +6,18 @@ 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 {distribute} from '@/util/inventory.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 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';
|
||||
|
@ -37,11 +40,14 @@ function Ui({disconnected}) {
|
|||
const chatInputRef = useRef();
|
||||
const latestTick = 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});
|
||||
|
@ -49,8 +55,6 @@ function Ui({disconnected}) {
|
|||
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);
|
||||
|
@ -63,25 +67,11 @@ function Ui({disconnected}) {
|
|||
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 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]);
|
||||
const [trading, setTrading] = useState(false);
|
||||
useEffect(() => {
|
||||
let handle;
|
||||
if (disconnected) {
|
||||
|
@ -236,20 +226,27 @@ function Ui({disconnected}) {
|
|||
});
|
||||
}
|
||||
});
|
||||
}, [client, keepHotbarOpen, setDebug]);
|
||||
}, [client, keepHotbarOpen, mainEntityRef, setDebug]);
|
||||
const onEcsChangePacket = useCallback(() => {
|
||||
refreshEcs();
|
||||
mainEntityRef.current = undefined;
|
||||
monopolizers.current = [];
|
||||
}, [refreshEcs, mainEntityRef]);
|
||||
}, [
|
||||
mainEntityRef,
|
||||
]);
|
||||
usePacket('EcsChange', onEcsChangePacket);
|
||||
const onTickPacket = useCallback(async (payload, client) => {
|
||||
if (0 === Object.keys(payload.ecs).length) {
|
||||
if (!ecsRef.current || 0 === Object.keys(payload.ecs).length) {
|
||||
return;
|
||||
}
|
||||
latestTick.current = Promise.resolve(latestTick.current).then(async () => {
|
||||
try {
|
||||
await ecsRef.current.apply(payload.ecs);
|
||||
client.emitter.invoke(':Ecs', payload.ecs);
|
||||
}
|
||||
catch (error) {
|
||||
ecsRef.current = undefined;
|
||||
console.error('tick crash', error);
|
||||
}
|
||||
});
|
||||
}, [ecsRef]);
|
||||
usePacket('Tick', onTickPacket);
|
||||
|
@ -263,13 +260,26 @@ function Ui({disconnected}) {
|
|||
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) {
|
||||
newHotbarSlots.push(entity.Inventory.item(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;
|
||||
});
|
||||
|
@ -285,6 +295,7 @@ function Ui({disconnected}) {
|
|||
newInventorySlots[i] = entity.Inventory.item(i);
|
||||
}
|
||||
setExternalInventory(entity.id)
|
||||
setTrading(!!entity.Shop);
|
||||
setExternalInventorySlots(newInventorySlots);
|
||||
setIsInventoryOpen(true);
|
||||
setHotbarIsHidden(false);
|
||||
|
@ -295,6 +306,9 @@ function Ui({disconnected}) {
|
|||
else if (update.Inventory.closed) {
|
||||
setExternalInventory();
|
||||
setExternalInventorySlots();
|
||||
setGaining([]);
|
||||
setLosing([]);
|
||||
setTrading(false);
|
||||
}
|
||||
}
|
||||
if (mainEntityRef.current === id) {
|
||||
|
@ -346,16 +360,15 @@ function Ui({disconnected}) {
|
|||
}
|
||||
}, []);
|
||||
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});
|
||||
const onEcsTickCamera = useCallback((payload) => {
|
||||
if (mainEntityRef.current && payload[mainEntityRef.current]?.Camera) {
|
||||
setCamera((camera) => ({
|
||||
x: camera.x,
|
||||
y: camera.y,
|
||||
...payload[mainEntityRef.current].Camera,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [camera, mainEntityRef, scale]);
|
||||
}, [mainEntityRef]);
|
||||
useEcsTick(onEcsTickCamera);
|
||||
useEffect(() => {
|
||||
function onContextMenu(event) {
|
||||
|
@ -367,7 +380,7 @@ function Ui({disconnected}) {
|
|||
};
|
||||
}, []);
|
||||
const computePosition = useCallback(({clientX, clientY}) => {
|
||||
if (!gameRef.current || !mainEntityRef.current) {
|
||||
if (!gameRef.current || !mainEntityRef.current || !ecsRef.current) {
|
||||
return;
|
||||
}
|
||||
const {top, left, width} = gameRef.current.getBoundingClientRect();
|
||||
|
@ -390,27 +403,207 @@ function Ui({disconnected}) {
|
|||
mainEntityRef,
|
||||
scale,
|
||||
]);
|
||||
const hotbarOnActivate = useCallback((i) => {
|
||||
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]},
|
||||
});
|
||||
}, [client, keepHotbarOpen, mainEntityRef]);
|
||||
const bagOnActivate = useCallback((i) => {
|
||||
}
|
||||
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, mainEntityRef]);
|
||||
}
|
||||
}, [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 (
|
||||
<div
|
||||
className={styles.ui}
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
@media (max-aspect-ratio: ${ratio}) { .${styles.game} { width: 100%; } }
|
||||
@media (min-aspect-ratio: ${ratio}) { .${styles.game} { height: 100%; } }
|
||||
@media (max-aspect-ratio: ${ratio}) { .${styles.game}, .${styles.ui} { width: 100%; } }
|
||||
@media (min-aspect-ratio: ${ratio}) { .${styles.game}, .${styles.ui} { height: 100%; } }
|
||||
.${styles.game} {
|
||||
cursor: ${
|
||||
bufferSlot
|
||||
|
@ -437,7 +630,7 @@ function Ui({disconnected}) {
|
|||
where,
|
||||
);
|
||||
}
|
||||
else {
|
||||
else if (!isInventoryOpen) {
|
||||
client.send({
|
||||
type: 'Action',
|
||||
payload: {type: 'use', value: [1, where]},
|
||||
|
@ -508,34 +701,46 @@ function Ui({disconnected}) {
|
|||
ref={gameRef}
|
||||
>
|
||||
<Pixi
|
||||
camera={camera}
|
||||
monopolizers={monopolizers.current}
|
||||
particleWorker={particleWorker}
|
||||
scale={scale}
|
||||
ref={pixiRef}
|
||||
/>
|
||||
<Dom>
|
||||
<HotBar
|
||||
active={activeSlot}
|
||||
highlighted={trading && losing.filter((i) => i < 11).map((i) => i - 1)}
|
||||
hotbarIsHidden={hotbarIsHidden}
|
||||
onActivate={hotbarOnActivate}
|
||||
onSlotMouseDown={hotbarOnSlotMouseDown}
|
||||
onSlotMouseMove={hotbarOnSlotMouseMove}
|
||||
onSlotMouseUp={hotbarOnSlotMouseUp}
|
||||
slots={hotbarSlots}
|
||||
/>
|
||||
<Bag
|
||||
highlighted={trading && losing.filter((i) => i >= 11).map((i) => i - 11)}
|
||||
isInventoryOpen={isInventoryOpen}
|
||||
onActivate={bagOnActivate}
|
||||
onSlotMouseDown={bagOnSlotMouseDown}
|
||||
slots={inventorySlots}
|
||||
/>
|
||||
{externalInventory && (
|
||||
<div className={styles.external}>
|
||||
<External
|
||||
highlighted={trading && gaining}
|
||||
isInventoryOpen={isInventoryOpen}
|
||||
onActivate={(i) => {
|
||||
client.send({
|
||||
type: 'Action',
|
||||
payload: {type: 'swapSlots', value: [0, externalInventory, i]},
|
||||
});
|
||||
}}
|
||||
onSlotMouseDown={externalInventoryOnSlotMouseDown}
|
||||
slots={externalInventorySlots}
|
||||
/>
|
||||
{trading && (
|
||||
<Trade
|
||||
isInventoryOpen={isInventoryOpen}
|
||||
onTradeAccepted={onTradeAccepted}
|
||||
gaining={gaining.map((slot) => externalInventorySlots[slot])}
|
||||
losing={losing.map((slot) => {
|
||||
return slot < 11 ? hotbarSlots[slot - 1] : inventorySlots[slot - 11];
|
||||
})}
|
||||
wallet={wallet}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Entities
|
||||
camera={camera}
|
||||
|
@ -560,6 +765,8 @@ function Ui({disconnected}) {
|
|||
{showDisconnected && (
|
||||
<Disconnected />
|
||||
)}
|
||||
<DateTime />
|
||||
<Wallet gold={wallet} />
|
||||
</Dom>
|
||||
</div>
|
||||
{devtoolsIsOpen && (
|
||||
|
|
|
@ -22,3 +22,13 @@
|
|||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.external {
|
||||
display: flex;
|
||||
left: 20px;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
top: 274px;
|
||||
transition: top 150ms, opacity 200ms;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import {createContext, useContext} from 'react';
|
||||
import {useCallback, useState} from 'react';
|
||||
|
||||
const context = createContext();
|
||||
|
||||
export default context;
|
||||
import useAnimationFrame from '@/react/hooks/use-animation-frame.js';
|
||||
import {TAU} from '@/util/math.js';
|
||||
|
||||
export function useRadians() {
|
||||
return useContext(context);
|
||||
const [radians, setRadians] = useState(0);
|
||||
useAnimationFrame(
|
||||
useCallback((elapsed) => {
|
||||
setRadians((radians) => radians + (elapsed * TAU));
|
||||
}, []),
|
||||
);
|
||||
return radians;
|
||||
}
|
||||
|
|
14
app/root.css
|
@ -1,5 +1,6 @@
|
|||
html, body {
|
||||
background-color: #333333;
|
||||
color: #cccccc;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
line-height: 0;
|
||||
|
@ -29,10 +30,19 @@ body {
|
|||
|
||||
@font-face {
|
||||
font-family: "Cookbook";
|
||||
src: url("/assets/fonts/Cookbook.woff");
|
||||
src: url("/fonts/Cookbook.woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Joystix";
|
||||
src: url("/assets/fonts/Joystix.woff");
|
||||
src: url("/fonts/Joystix.woff");
|
||||
}
|
||||
|
||||
form {
|
||||
button, input {
|
||||
background-color: #222222;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
color: #dddddd;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import {json} from "@remix-run/node";
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useOutletContext, useParams} from 'react-router-dom';
|
||||
|
||||
|
@ -8,15 +7,8 @@ import ClientContext from '@/react/context/client.js';
|
|||
import DebugContext from '@/react/context/debug.js';
|
||||
import EcsContext from '@/react/context/ecs.js';
|
||||
import MainEntityContext from '@/react/context/main-entity.js';
|
||||
import RadiansContext from '@/react/context/radians.js';
|
||||
import useAnimationFrame from '@/react/hooks/use-animation-frame.js';
|
||||
import {juggleSession} from '@/server/session.server.js';
|
||||
import {TAU} from '@/util/math.js';
|
||||
|
||||
export async function loader({request}) {
|
||||
await juggleSession(request);
|
||||
return json({});
|
||||
}
|
||||
import ClientEcs from '@/react/components/client-ecs.js';
|
||||
|
||||
export default function PlaySpecific() {
|
||||
const Client = useOutletContext();
|
||||
|
@ -24,15 +16,12 @@ export default function PlaySpecific() {
|
|||
const [client, setClient] = useState();
|
||||
const mainEntityRef = useRef();
|
||||
const debugTuple = useState(false);
|
||||
const [Components, setComponents] = useState();
|
||||
const [Systems, setSystems] = useState();
|
||||
const ecsRef = useRef();
|
||||
const [disconnected, setDisconnected] = useState(false);
|
||||
const params = useParams();
|
||||
const [type, url] = params['*'].split('/');
|
||||
const [radians, setRadians] = useState(0);
|
||||
const spin = useCallback((elapsed) => {
|
||||
setRadians((radians) => radians + (elapsed * TAU));
|
||||
}, []);
|
||||
useAnimationFrame(spin);
|
||||
useEffect(() => {
|
||||
if (!Client) {
|
||||
return;
|
||||
|
@ -64,6 +53,37 @@ export default function PlaySpecific() {
|
|||
removeEventListener('beforeunload', onBeforeUnload);
|
||||
};
|
||||
});
|
||||
const refreshEcs = useCallback(() => {
|
||||
class ClientEcsPerf extends ClientEcs {
|
||||
markChange() {}
|
||||
}
|
||||
ecsRef.current = new ClientEcsPerf({Components, Systems});
|
||||
mainEntityRef.current = undefined;
|
||||
}, [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(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
function onEcsChangePacket() {
|
||||
refreshEcs();
|
||||
}
|
||||
client.addPacketListener('EcsChange', onEcsChangePacket);
|
||||
return () => {
|
||||
client.removePacketListener('EcsChange', onEcsChangePacket);
|
||||
};
|
||||
}, [client, refreshEcs]);
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
|
@ -71,6 +91,7 @@ export default function PlaySpecific() {
|
|||
function onConnectionStatus(status) {
|
||||
switch (status) {
|
||||
case 'aborted': {
|
||||
client.disconnect();
|
||||
setDisconnected(true);
|
||||
break;
|
||||
}
|
||||
|
@ -108,21 +129,16 @@ export default function PlaySpecific() {
|
|||
if (!client || !disconnected) {
|
||||
return;
|
||||
}
|
||||
mainEntityRef.current = undefined;
|
||||
async function reconnect() {
|
||||
await client.connect(url);
|
||||
}
|
||||
reconnect();
|
||||
const handle = setInterval(reconnect, 1000);
|
||||
return () => {
|
||||
clearInterval(handle);
|
||||
};
|
||||
}, [client, disconnected, mainEntityRef, url]);
|
||||
// useEffect(() => {
|
||||
// let source = true;
|
||||
// async function play() {
|
||||
// const ctx = new AudioContext();
|
||||
// const response = await fetch(new URL('/assets/yuff.wav', window.location.origin));
|
||||
// const response = await fetch(new URL('/resources/yuff.wav', window.location.origin));
|
||||
// const buffer = await ctx.decodeAudioData(await response.arrayBuffer());
|
||||
// if (!source) {
|
||||
// return;
|
||||
|
@ -147,9 +163,7 @@ export default function PlaySpecific() {
|
|||
<EcsContext.Provider value={ecsRef}>
|
||||
<DebugContext.Provider value={debugTuple}>
|
||||
<AssetsContext.Provider value={assetsTuple}>
|
||||
<RadiansContext.Provider value={radians}>
|
||||
<Ui disconnected={disconnected} />
|
||||
</RadiansContext.Provider>
|
||||
</AssetsContext.Provider>
|
||||
</DebugContext.Provider>
|
||||
</EcsContext.Provider>
|
||||
|
|
|
@ -1,12 +1,66 @@
|
|||
import {settings} from '@pixi/core';
|
||||
import {json, useLoaderData} from "@remix-run/react";
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Outlet, useParams} from 'react-router-dom';
|
||||
|
||||
import {
|
||||
computeMissing,
|
||||
fetchResources,
|
||||
get,
|
||||
readAsset,
|
||||
set,
|
||||
} from '@/util/resources.js';
|
||||
|
||||
import styles from './play.module.css';
|
||||
|
||||
settings.ADAPTER.fetch = async (path) => {
|
||||
const resource = await readAsset(path);
|
||||
return resource ? new Response(resource) : new Response(undefined, {status: 404});
|
||||
};
|
||||
|
||||
export async function loader({request}) {
|
||||
const {juggleSession} = await import('@/server/session.server.js');
|
||||
const {loadManifest} = await import('@/util/resources.server.js');
|
||||
await juggleSession(request);
|
||||
return json({
|
||||
manifest: await loadManifest(),
|
||||
});
|
||||
}
|
||||
|
||||
export default function Play() {
|
||||
const {manifest} = useLoaderData();
|
||||
const [Client, setClient] = useState();
|
||||
const params = useParams();
|
||||
const [type] = params['*'].split('/');
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const {signal} = controller;
|
||||
async function receiveResources() {
|
||||
const current = await get();
|
||||
const paths = await computeMissing(current, manifest);
|
||||
if (paths.length > 0 && !signal.aborted) {
|
||||
try {
|
||||
const resources = await fetchResources(paths, {signal});
|
||||
if (resources) {
|
||||
for (const key in resources) {
|
||||
current[key] = resources[key];
|
||||
}
|
||||
await set(current);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if ((e instanceof DOMException) && 'AbortError' === e.name) {
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
receiveResources();
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [manifest]);
|
||||
useEffect(() => {
|
||||
async function loadClient() {
|
||||
let Client;
|
||||
|
|
10
app/routes/dev.gen._index/layer.jsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import styles from './layer.module.css';
|
||||
|
||||
function Layer() {
|
||||
return (
|
||||
<>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layer;
|
0
app/routes/dev.gen._index/layer.module.css
Normal file
51
app/routes/dev.gen._index/noise-field.jsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import SliderText from '@/react/components/dev/slider-text.jsx';
|
||||
|
||||
import styles from './noise-field.module.css';
|
||||
|
||||
function NoiseField(props) {
|
||||
const {children, ...field} = props;
|
||||
return (
|
||||
<div className={styles.noiseField}>
|
||||
<input
|
||||
name="label[]"
|
||||
type="text"
|
||||
defaultValue={field.label}
|
||||
/>
|
||||
<label>
|
||||
<SliderText
|
||||
name="percent[]"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
defaultValue={field.percent}
|
||||
/>
|
||||
<span>%</span>
|
||||
</label>
|
||||
<div className={styles.scale}>
|
||||
<label>
|
||||
<span>x</span>
|
||||
<SliderText
|
||||
defaultValue={field.scale.x}
|
||||
max="100"
|
||||
min="0.01"
|
||||
name="scaleX[]"
|
||||
step="0.01"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>y</span>
|
||||
<SliderText
|
||||
defaultValue={field.scale.y}
|
||||
max="100"
|
||||
min="0.01"
|
||||
name="scaleY[]"
|
||||
step="0.01"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoiseField;
|
16
app/routes/dev.gen._index/noise-field.module.css
Normal file
|
@ -0,0 +1,16 @@
|
|||
.noiseField {
|
||||
gap: 8px;
|
||||
&, label {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
[type="text"][name^="label"] {
|
||||
width: 10em;
|
||||
}
|
||||
[type="text"][name^="percent"] {
|
||||
width: 3em;
|
||||
}
|
||||
[type="text"][name^="scale"] {
|
||||
width: 4em;
|
||||
}
|
||||
}
|
107
app/routes/dev.gen._index/route.jsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
// import {Container, Sprite, Stage} from '@pixi/react';
|
||||
import {Form, json, redirect, useLoaderData} from '@remix-run/react';
|
||||
import {useState} from 'react';
|
||||
|
||||
import {commitSession, getSession, juggleSession} from '@/server/session.server.js';
|
||||
|
||||
import NoiseField from './noise-field.jsx';
|
||||
|
||||
function getFormSession(session) {
|
||||
return session.get('formSession') || {
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({request}) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const formSession = getFormSession(session);
|
||||
const formData = await request.formData();
|
||||
const op = formData.get('op');
|
||||
if ('add-field' === op) {
|
||||
formSession.fields.push({
|
||||
label: '',
|
||||
percent: 100,
|
||||
scale: {x: 1, y: 1},
|
||||
});
|
||||
}
|
||||
else if (op.startsWith('delete/')) {
|
||||
const index = parseInt(op.slice('delete/'.length));
|
||||
if (index >= 0 && index < formSession.fields.length) {
|
||||
formSession.fields.splice(index, 1);
|
||||
}
|
||||
else {
|
||||
throw new Error('delete out of bounds');
|
||||
}
|
||||
}
|
||||
else if (op.startsWith('update/')) {
|
||||
const index = parseInt(op.slice('update/'.length));
|
||||
if (index >= 0 && index < formSession.fields.length) {
|
||||
formSession.fields[index] = {
|
||||
label: formData.getAll('label[]')[index],
|
||||
percent: formData.getAll('percent[]')[index],
|
||||
scale: {
|
||||
x: formData.getAll('scaleX[]')[index],
|
||||
y: formData.getAll('scaleY[]')[index],
|
||||
},
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error('delete out of bounds');
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error('unknown operation');
|
||||
}
|
||||
session.set('formSession', formSession);
|
||||
return json(null, {
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function loader({request}) {
|
||||
const session = await juggleSession(request);
|
||||
return json({
|
||||
formSession: getFormSession(session),
|
||||
});
|
||||
}
|
||||
|
||||
function Gen() {
|
||||
const {formSession: {fields}} = useLoaderData();
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
method="post"
|
||||
>
|
||||
{fields.map((field, i) => (
|
||||
<NoiseField
|
||||
key={i}
|
||||
{...field}
|
||||
>
|
||||
<button
|
||||
name="op"
|
||||
value={['update', i].join('/')}
|
||||
>
|
||||
o
|
||||
</button>
|
||||
<button
|
||||
name="op"
|
||||
value={['delete', i].join('/')}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</NoiseField>
|
||||
))}
|
||||
<button
|
||||
name="op"
|
||||
value="add-field"
|
||||
>
|
||||
Add field
|
||||
</button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Gen;
|
185
app/routes/dev.gen.forest/route.jsx
Normal file
|
@ -0,0 +1,185 @@
|
|||
import {Container, Sprite, Stage} from '@pixi/react';
|
||||
import {useState} from 'react';
|
||||
|
||||
import SliderText from '@/react/components/dev/slider-text.jsx';
|
||||
import TileLayer from '@/react/components/pixi/tile-layer.jsx';
|
||||
import AssetsContext from '@/react/context/assets.js';
|
||||
import {CHUNK_SIZE} from '@/util/constants.js';
|
||||
|
||||
import alea from 'alea';
|
||||
import {createNoise2D} from 'simplex-noise';
|
||||
|
||||
// const seed = 3;
|
||||
|
||||
const dirt = [342, 456];
|
||||
const grass = [1, 3, 4, 10, 11, 12];
|
||||
// const stone = [407, 408, 423, 424];
|
||||
// const water = [103, 104];
|
||||
|
||||
class Generator {
|
||||
constructor({
|
||||
area,
|
||||
passes,
|
||||
seed,
|
||||
}) {
|
||||
const prng = alea(seed);
|
||||
const rawNoise = createNoise2D(prng);
|
||||
this.noise = (x, y) => (1 + rawNoise(x, y)) / 2;
|
||||
this.area = area;
|
||||
this.passes = passes;
|
||||
}
|
||||
generate(fn) {
|
||||
for (let y = 0; y < this.area.y; ++y) {
|
||||
for (let x = 0; x < this.area.x; ++x) {
|
||||
for (let j = this.passes.length - 1; j >=0; --j) {
|
||||
if (this.passes[j].test(x, y, this.noise)) {
|
||||
fn(this.passes[j].compute(x, y, this.noise), x, y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Gen() {
|
||||
const area = {x: 80, y: 80};
|
||||
const assetsTuple = useState({});
|
||||
const [seed, setSeed] = useState(0);
|
||||
const [dirtClump, setDirtClump] = useState(60);
|
||||
const [dirtPer, setDirtPer] = useState(0.6);
|
||||
|
||||
const dirtCheck = (x, y, noise) => noise(x / dirtClump, y / dirtClump) < dirtPer;
|
||||
|
||||
const tilePasses = [
|
||||
{
|
||||
test: () => 1,
|
||||
compute: (x, y, noise) => grass[Math.floor(noise(x, y) * grass.length)],
|
||||
},
|
||||
{
|
||||
test: dirtCheck,
|
||||
compute: (x, y, noise) => dirt[Math.floor(noise(x, y) * dirt.length)],
|
||||
},
|
||||
{
|
||||
test: (x, y, noise) => noise(x / 60, y / 60) < 0.25,
|
||||
compute: () => 103,
|
||||
},
|
||||
{
|
||||
test: (x, y, noise) => noise(x / 60, y / 60) < 0.14,
|
||||
compute: () => 104,
|
||||
},
|
||||
];
|
||||
|
||||
const entityPasses = [
|
||||
{
|
||||
test: (x, y, noise) => (
|
||||
dirtCheck(x, y, noise)
|
||||
&& !(noise(x / 60, y / 60) < 0.25)
|
||||
&& noise(x, y) < 0.2
|
||||
),
|
||||
compute: (x, y) => ({
|
||||
anchor:{x: 0.5, y: 0.875},
|
||||
image: '/resources/ambient/tree.png',
|
||||
x: x * 16,
|
||||
y: y * 16,
|
||||
}),
|
||||
},
|
||||
{
|
||||
test: (x, y, noise) => (
|
||||
!dirtCheck(x, y, noise)
|
||||
&& !(noise(x / 60, y / 60) < 0.25)
|
||||
&& noise(x / 5, y / 5) < 0.15
|
||||
),
|
||||
compute: (x, y, noise) => ({
|
||||
anchor:{x: 0.5, y: 0.7},
|
||||
image: '/resources/ambient/flower.png',
|
||||
x: x * 16 + (noise(x, y) * 8 - 4),
|
||||
y: y * 16 + (noise(y, x) * 8 - 4),
|
||||
}),
|
||||
},
|
||||
{
|
||||
test: (x, y, noise) => (
|
||||
!dirtCheck(x, y, noise)
|
||||
&& !(noise(x / 60, y / 60) < 0.25)
|
||||
&& noise(x / 10, y / 10) < 0.2
|
||||
),
|
||||
compute: (x, y, noise) => ({
|
||||
anchor:{x: 0.5, y: 0.7},
|
||||
image: '/resources/ambient/shrub.png',
|
||||
x: x * 16 + (noise(x, y) * 8 - 4),
|
||||
y: y * 16 + (noise(y, x) * 8 - 4),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const layer = {
|
||||
area,
|
||||
$$chunks: Array(
|
||||
Math.ceil(area.x / CHUNK_SIZE) * Math.ceil(area.y / CHUNK_SIZE)
|
||||
).fill(0).map(() => ({})),
|
||||
data: Array(area.x * area.y).fill(1),
|
||||
source: '/resources/tileset.json',
|
||||
tileSize: {x: 16, y: 16},
|
||||
};
|
||||
const tileGenerator = new Generator({
|
||||
area,
|
||||
passes: tilePasses,
|
||||
seed,
|
||||
});
|
||||
tileGenerator.generate((tile, x, y) => {
|
||||
layer.data[y * area.x + x] = tile;
|
||||
});
|
||||
const entities = [];
|
||||
const entityGenerator = new Generator({
|
||||
area,
|
||||
passes: entityPasses,
|
||||
seed,
|
||||
});
|
||||
entityGenerator.generate((entity) => {
|
||||
entities.push(entity);
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Stage
|
||||
width={640}
|
||||
height={640}
|
||||
options={{
|
||||
background: 0x0,
|
||||
}}
|
||||
>
|
||||
<AssetsContext.Provider value={assetsTuple}>
|
||||
<Container
|
||||
scale={0.5}
|
||||
>
|
||||
<TileLayer
|
||||
tileLayer={layer}
|
||||
/>
|
||||
{entities.map((entity, i) => (
|
||||
<Sprite
|
||||
key={i}
|
||||
{...entity}
|
||||
/>
|
||||
))}
|
||||
</Container>
|
||||
</AssetsContext.Provider>
|
||||
</Stage>
|
||||
<input type="text" onChange={({currentTarget: {value}}) => setSeed(value || 0)} value={seed} />
|
||||
<SliderText
|
||||
max="1"
|
||||
min="0"
|
||||
onChange={(value) => setDirtPer(value)}
|
||||
step="0.05"
|
||||
defaultValue={dirtPer}
|
||||
/>
|
||||
<SliderText
|
||||
max="100"
|
||||
min="1"
|
||||
onChange={(value) => setDirtClump(value)}
|
||||
step="1"
|
||||
defaultValue={dirtClump}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Gen;
|
8
app/routes/resources.stream/route.jsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import {encodeResources, loadResources} from '@/util/resources.server.js';
|
||||
|
||||
export async function action({request}) {
|
||||
const paths = await request.json();
|
||||
const assets = await loadResources();
|
||||
const buffer = await encodeResources(paths.map((path) => assets[path]));
|
||||
return new Response(buffer);
|
||||
}
|
|
@ -63,13 +63,13 @@ export default async function createForest() {
|
|||
{
|
||||
area,
|
||||
data: Array(w * h).fill(0),
|
||||
source: '/assets/tileset.json',
|
||||
source: '/resources/tileset.json',
|
||||
tileSize: {x: 16, y: 16},
|
||||
},
|
||||
{
|
||||
area,
|
||||
data: Array(w * h).fill(0),
|
||||
source: '/assets/tileset.json',
|
||||
source: '/resources/tileset.json',
|
||||
tileSize: {x: 16, y: 16},
|
||||
},
|
||||
],
|
||||
|
@ -113,7 +113,7 @@ export default async function createForest() {
|
|||
Position: entityPosition(x, y),
|
||||
Sprite: {
|
||||
anchorY: 0.7,
|
||||
source: '/assets/ambient/shrub.json',
|
||||
source: '/resources/ambient/shrub.json',
|
||||
},
|
||||
VisibleAabb: {},
|
||||
});
|
||||
|
@ -123,7 +123,7 @@ export default async function createForest() {
|
|||
Position: entityPosition(x, y),
|
||||
Sprite: {
|
||||
anchorY: 0.875,
|
||||
source: '/assets/ambient/tree.json',
|
||||
source: '/resources/ambient/tree.json',
|
||||
},
|
||||
VisibleAabb: {},
|
||||
});
|
||||
|
@ -134,7 +134,7 @@ export default async function createForest() {
|
|||
Position: entityPosition(x, y),
|
||||
Sprite: {
|
||||
anchorY: 0.7,
|
||||
source: '/assets/ambient/flower.json',
|
||||
source: '/resources/ambient/flower.json',
|
||||
},
|
||||
VisibleAabb: {},
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import data from '../../../public/assets/dev/homestead.json';
|
||||
import data from './homestead.json';
|
||||
|
||||
function animal() {
|
||||
return {
|
||||
|
@ -22,10 +22,9 @@ function animal() {
|
|||
};
|
||||
}
|
||||
|
||||
export default async function createHomestead(id) {
|
||||
function createMaster() {
|
||||
const area = {x: 100, y: 60};
|
||||
const entities = [];
|
||||
entities.push({
|
||||
return {
|
||||
AreaSize: {x: area.x * 16, y: area.y * 16},
|
||||
Ticking: {},
|
||||
TileLayers: {
|
||||
|
@ -33,21 +32,24 @@ export default async function createHomestead(id) {
|
|||
{
|
||||
area,
|
||||
data,
|
||||
source: '/assets/tileset.json',
|
||||
source: '/resources/tileset.json',
|
||||
tileSize: {x: 16, y: 16},
|
||||
},
|
||||
{
|
||||
area,
|
||||
data: Array(area.x * area.y).fill(0),
|
||||
source: '/assets/tileset.json',
|
||||
source: '/resources/tileset.json',
|
||||
tileSize: {x: 16, y: 16},
|
||||
},
|
||||
],
|
||||
},
|
||||
Time: {},
|
||||
Water: {water: {}},
|
||||
});
|
||||
entities.push({
|
||||
};
|
||||
}
|
||||
|
||||
function createShitShack(id) {
|
||||
return {
|
||||
Collider: {
|
||||
bodies: [
|
||||
{
|
||||
|
@ -68,12 +70,15 @@ export default async function createHomestead(id) {
|
|||
Sprite: {
|
||||
anchorX: 0.5,
|
||||
anchorY: 0.8,
|
||||
source: '/assets/shit-shack/shit-shack.json',
|
||||
source: '/resources/shit-shack/shit-shack.json',
|
||||
},
|
||||
Ticking: {},
|
||||
VisibleAabb: {},
|
||||
});
|
||||
entities.push({
|
||||
};
|
||||
}
|
||||
|
||||
function createHouseTeleport(id) {
|
||||
return {
|
||||
Collider: {
|
||||
bodies: [
|
||||
{
|
||||
|
@ -102,8 +107,11 @@ export default async function createHomestead(id) {
|
|||
},
|
||||
Position: {x: 71, y: 113},
|
||||
Ticking: {},
|
||||
});
|
||||
entities.push({
|
||||
};
|
||||
}
|
||||
|
||||
function createChest() {
|
||||
return {
|
||||
Collider: {
|
||||
bodies: [
|
||||
{
|
||||
|
@ -135,38 +143,83 @@ export default async function createHomestead(id) {
|
|||
slots: {
|
||||
2: {
|
||||
qty: 1,
|
||||
source: '/assets/watering-can/watering-can.json',
|
||||
source: '/resources/watering-can/watering-can.json',
|
||||
},
|
||||
3: {
|
||||
qty: 1,
|
||||
source: '/assets/tomato-seeds/tomato-seeds.json',
|
||||
source: '/resources/tomato-seeds/tomato-seeds.json',
|
||||
},
|
||||
4: {
|
||||
qty: 1,
|
||||
source: '/assets/hoe/hoe.json',
|
||||
source: '/resources/hoe/hoe.json',
|
||||
},
|
||||
5: {
|
||||
qty: 1,
|
||||
source: '/assets/brush/brush.json',
|
||||
source: '/resources/brush/brush.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
Position: {x: 200, y: 200},
|
||||
Shop: {},
|
||||
Sprite: {
|
||||
anchorX: 0.5,
|
||||
anchorY: 0.7,
|
||||
source: '/assets/chest/chest.json',
|
||||
source: '/resources/chest/chest.json',
|
||||
},
|
||||
Ticking: {},
|
||||
VisibleAabb: {},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createTomato() {
|
||||
return {
|
||||
Collider: {
|
||||
bodies: [
|
||||
{
|
||||
points: [
|
||||
{x: -4, y: -4},
|
||||
{x: 3, y: -4},
|
||||
{x: 3, y: 3},
|
||||
{x: -4, y: 3},
|
||||
],
|
||||
},
|
||||
],
|
||||
collisionStartScript: [
|
||||
'if (other.Inventory) {',
|
||||
' other.Inventory.give({',
|
||||
' qty: 1,',
|
||||
" source: '/resources/tomato/tomato.json',",
|
||||
' })',
|
||||
' ecs.destroy(entity.id)',
|
||||
' undefined;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
},
|
||||
Forces: {},
|
||||
Magnetic: {},
|
||||
Position: {
|
||||
x: 168 + Math.random() * 30,
|
||||
y: 448 + Math.random() * 30,
|
||||
},
|
||||
Sprite: {
|
||||
anchorX: 0.5,
|
||||
anchorY: 0.5,
|
||||
scaleX: 0.333,
|
||||
scaleY: 0.333,
|
||||
source: '/resources/tomato/tomato-sprite.json',
|
||||
},
|
||||
Ticking: {},
|
||||
VisibleAabb: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createTestKitten() {
|
||||
const animalJson = animal();
|
||||
for (let i = 0; i < 50; ++i) {
|
||||
entities.push({
|
||||
return {
|
||||
...animalJson,
|
||||
Behaving: {
|
||||
routines: {
|
||||
initial: '/assets/kitty/initial.js',
|
||||
initial: '/resources/kitty/initial.js',
|
||||
},
|
||||
},
|
||||
Collider: {
|
||||
|
@ -209,18 +262,20 @@ export default async function createHomestead(id) {
|
|||
...animalJson.Sprite,
|
||||
anchorX: 0.5,
|
||||
anchorY: 0.7,
|
||||
source: '/assets/kitty/kitty.json',
|
||||
source: '/resources/kitty/kitty.json',
|
||||
speed: 0.115,
|
||||
},
|
||||
Tags: {tags: ['kittan']},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 50; ++i) {
|
||||
entities.push({
|
||||
};
|
||||
}
|
||||
|
||||
function createTestCow() {
|
||||
const animalJson = animal();
|
||||
return {
|
||||
...animalJson,
|
||||
Behaving: {
|
||||
routines: {
|
||||
initial: '/assets/farm/animals/cow-adult/initial.js',
|
||||
initial: '/resources/farm/animals/cow-adult/initial.js',
|
||||
},
|
||||
},
|
||||
Collider: {
|
||||
|
@ -260,17 +315,19 @@ export default async function createHomestead(id) {
|
|||
...animalJson.Sprite,
|
||||
anchorX: 0.5,
|
||||
anchorY: 0.8,
|
||||
source: '/assets/farm/animals/cow-adult/cow-adult.json',
|
||||
source: '/resources/farm/animals/cow-adult/cow-adult.json',
|
||||
speed: 0.25,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 50; ++i) {
|
||||
entities.push({
|
||||
};
|
||||
}
|
||||
|
||||
function createTestGoat() {
|
||||
const animalJson = animal();
|
||||
return {
|
||||
...animalJson,
|
||||
Behaving: {
|
||||
routines: {
|
||||
initial: '/assets/farm/animals/goat-white/initial.js',
|
||||
initial: '/resources/farm/animals/goat-white/initial.js',
|
||||
},
|
||||
},
|
||||
Collider: {
|
||||
|
@ -309,12 +366,14 @@ export default async function createHomestead(id) {
|
|||
...animalJson.Sprite,
|
||||
anchorX: 0.5,
|
||||
anchorY: 0.8,
|
||||
source: '/assets/farm/animals/goat-white/goat-white.json',
|
||||
source: '/resources/farm/animals/goat-white/goat-white.json',
|
||||
speed: 0.25,
|
||||
},
|
||||
});
|
||||
}
|
||||
entities.push({
|
||||
};
|
||||
}
|
||||
|
||||
function createTownTeleport() {
|
||||
return {
|
||||
Collider: {
|
||||
bodies: [
|
||||
{
|
||||
|
@ -344,6 +403,65 @@ export default async function createHomestead(id) {
|
|||
|
||||
Position: {x: 8, y: 432},
|
||||
Ticking: {},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createTomatoPlant() {
|
||||
return {
|
||||
Collider: {
|
||||
bodies: [
|
||||
{
|
||||
points: [
|
||||
{x: -8, y: -8},
|
||||
{x: 7, y: -8},
|
||||
{x: -8, y: 7},
|
||||
{x: 7, y: 7},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Interactive: {
|
||||
interactScript: '/resources/tomato-plant/interact.js',
|
||||
},
|
||||
Plant: {
|
||||
growScript: '/resources/tomato-plant/grow.js',
|
||||
growthFactor: Math.floor(Math.random() * 256),
|
||||
mayGrowScript: '/resources/tomato-plant/may-grow.js',
|
||||
stages: [0.5, 0.5, 0.5, 0.5, 0.5],
|
||||
},
|
||||
Position: {
|
||||
x: 168,
|
||||
y: 440,
|
||||
},
|
||||
Sprite: {
|
||||
anchorY: 0.75,
|
||||
animation: 'stage/0',
|
||||
source: '/resources/tomato-plant/tomato-plant.json',
|
||||
},
|
||||
Ticking: {},
|
||||
VisibleAabb: {},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function createHomestead(id) {
|
||||
const entities = [];
|
||||
entities.push(createMaster());
|
||||
entities.push(createShitShack(id));
|
||||
entities.push(createHouseTeleport(id));
|
||||
entities.push(createChest());
|
||||
// for (let i = 0; i < 200; ++i) {
|
||||
// entities.push(createTomato());
|
||||
// }
|
||||
entities.push(createTomatoPlant());
|
||||
// for (let i = 0; i < 10; ++i) {
|
||||
// entities.push(createTestKitten());
|
||||
// }
|
||||
// for (let i = 0; i < 10; ++i) {
|
||||
// entities.push(createTestCow());
|
||||
// }
|
||||
// for (let i = 0; i < 10; ++i) {
|
||||
// entities.push(createTestGoat());
|
||||
// }
|
||||
entities.push(createTownTeleport());
|
||||
return entities;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ export default async function createHouse(Ecs, id) {
|
|||
{
|
||||
area,
|
||||
data: Array(area.x * area.y).fill(0).map(() => 5 + Math.floor(Math.random() * 2)),
|
||||
source: '/assets/tileset.json',
|
||||
source: '/resources/tileset.json',
|
||||
tileSize: {x: 16, y: 16},
|
||||
}
|
||||
],
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export default async function createPlayer(id) {
|
||||
const player = {
|
||||
Alive: {health: 100},
|
||||
Camera: {},
|
||||
Collider: {
|
||||
bodies: [
|
||||
|
@ -24,11 +25,31 @@ export default async function createPlayer(id) {
|
|||
slots: {
|
||||
1: {
|
||||
qty: 100,
|
||||
source: '/assets/potion/potion.json',
|
||||
source: '/resources/potion/potion.json',
|
||||
},
|
||||
2: {
|
||||
qty: 1,
|
||||
source: '/assets/magic-swords/magic-swords.json',
|
||||
source: '/resources/magic-swords/magic-swords.json',
|
||||
},
|
||||
3: {
|
||||
qty: 1,
|
||||
source: '/resources/watering-can/watering-can.json',
|
||||
},
|
||||
4: {
|
||||
qty: 1,
|
||||
source: '/resources/tomato-seeds/tomato-seeds.json',
|
||||
},
|
||||
5: {
|
||||
qty: 1,
|
||||
source: '/resources/hoe/hoe.json',
|
||||
},
|
||||
6: {
|
||||
qty: 95,
|
||||
source: '/resources/potion/potion.json',
|
||||
},
|
||||
7: {
|
||||
qty: 95,
|
||||
source: '/resources/potion/potion.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -44,11 +65,15 @@ export default async function createPlayer(id) {
|
|||
anchorY: 0.9,
|
||||
animation: 'moving:down',
|
||||
frame: 0,
|
||||
source: '/assets/dude/dude.json',
|
||||
source: '/resources/dude/dude.json',
|
||||
speed: 0.115,
|
||||
},
|
||||
Ticking: {},
|
||||
VisibleAabb: {},
|
||||
Vulnerable: {},
|
||||
Wallet: {
|
||||
gold: 1000,
|
||||
},
|
||||
Wielder: {
|
||||
activeSlot: 1,
|
||||
},
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import data from '../../../public/assets/dev/town.json';
|
||||
import data from './town.json';
|
||||
|
||||
export default async function createTown() {
|
||||
const area = {x: 60, y: 60};
|
||||
|
@ -11,13 +11,13 @@ export default async function createTown() {
|
|||
{
|
||||
area,
|
||||
data,
|
||||
source: '/assets/tileset.json',
|
||||
source: '/resources/tileset.json',
|
||||
tileSize: {x: 16, y: 16},
|
||||
},
|
||||
{
|
||||
area,
|
||||
data: Array(area.x * area.y).fill(0),
|
||||
source: '/assets/tileset.json',
|
||||
source: '/resources/tileset.json',
|
||||
tileSize: {x: 16, y: 16},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -130,6 +130,9 @@ export default class Engine {
|
|||
});
|
||||
this.server.addPacketListener('Heartbeat', (connection) => {
|
||||
const playerData = this.connectedPlayers.get(connection);
|
||||
if (!playerData) {
|
||||
return;
|
||||
}
|
||||
const {distance} = playerData;
|
||||
const now = performance.now();
|
||||
distance.rtt = (now - distance.last) / 1000;
|
||||
|
@ -163,6 +166,8 @@ export default class Engine {
|
|||
Interacts,
|
||||
Interlocutor,
|
||||
Inventory,
|
||||
Player,
|
||||
Wallet,
|
||||
Wielder,
|
||||
} = entity;
|
||||
const ecs = this.ecses[Ecs.path];
|
||||
|
@ -228,6 +233,40 @@ export default class Engine {
|
|||
Controlled[payload.type] = payload.value;
|
||||
break;
|
||||
}
|
||||
case 'acceptTrade': {
|
||||
const {losing, gaining} = payload.value;
|
||||
const gainingSlots = gaining.filter((slot) => Player.openInventory.item(slot));
|
||||
const losingSlots = losing.filter((slot) => Inventory.item(slot));
|
||||
const nop = 0 === gainingSlots.length && 0 === losingSlots.length;
|
||||
const gainingItems = gainingSlots.map((slot) => Player.openInventory.item(slot));
|
||||
const losingItems = losingSlots.map((slot) => Inventory.item(slot));
|
||||
if (nop) {
|
||||
break;
|
||||
}
|
||||
const reducePrice = (r, item) => {
|
||||
return r + item.price * item.qty;
|
||||
};
|
||||
const earn = losingItems.reduce(reducePrice, 0) - gainingItems.reduce(reducePrice, 0);
|
||||
const canAfford = -earn <= Wallet.gold;
|
||||
if (!canAfford) {
|
||||
break;
|
||||
}
|
||||
for (const slot of losingSlots) {
|
||||
Inventory.clear(slot);
|
||||
}
|
||||
for (const item of gainingItems) {
|
||||
Inventory.give({qty: item.qty, source: item.source});
|
||||
}
|
||||
Wallet.gold += earn;
|
||||
break;
|
||||
}
|
||||
case 'distribute': {
|
||||
if (!Controlled.locked) {
|
||||
const [slot, destinations] = payload.value;
|
||||
Inventory.distribute(slot, destinations);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'swapSlots': {
|
||||
if (!Controlled.locked) {
|
||||
const [l, other, r] = payload.value;
|
||||
|
@ -449,7 +488,13 @@ export default class Engine {
|
|||
loop();
|
||||
}
|
||||
|
||||
stop() {
|
||||
async stop() {
|
||||
const promises = [];
|
||||
for (const [connection] of this.connectedPlayers) {
|
||||
promises.push(this.disconnectPlayer(connection));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
await this.saveEcses();
|
||||
clearTimeout(this.handle);
|
||||
this.handle = undefined;
|
||||
}
|
||||
|
|
|
@ -8,40 +8,11 @@ import {getSession} from '@/server/session.server.js';
|
|||
|
||||
import Engine from './engine.js';
|
||||
|
||||
const isInsecure = process.env.SILPHIUS_INSECURE_HTTP;
|
||||
const {
|
||||
RESOURCES_PATH = [process.cwd(), 'resources'].join('/'),
|
||||
} = process.env;
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
});
|
||||
|
||||
function onUpgrade(request, socket, head) {
|
||||
const {pathname} = new URL(request.url, 'wss://base.url');
|
||||
if (pathname === '/ws') {
|
||||
wss.handleUpgrade(request, socket, head, function done(ws) {
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
}
|
||||
else {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
let engine;
|
||||
let onConnect;
|
||||
|
||||
function createOnConnect(engine) {
|
||||
onConnect = async (ws, request) => {
|
||||
ws.on('close', async () => {
|
||||
await engine.disconnectPlayer(ws);
|
||||
})
|
||||
ws.on('message', (packed) => {
|
||||
engine.server.accept(ws, new DataView(packed.buffer, packed.byteOffset, packed.length));
|
||||
});
|
||||
const session = await getSession(request.headers['cookie']);
|
||||
await engine.connectPlayer(ws, session.get('id'));
|
||||
};
|
||||
wss.on('connection', onConnect);
|
||||
}
|
||||
global.__silphiusWebsocket = null;
|
||||
|
||||
class SocketServer extends Server {
|
||||
async ensurePath(path) {
|
||||
|
@ -51,13 +22,18 @@ class SocketServer extends Server {
|
|||
return join(import.meta.dirname, '..', '..', 'data', 'remote', 'UNIVERSE', path);
|
||||
}
|
||||
async readAsset(path) {
|
||||
const url = new URL(path, 'https://localhost:3000')
|
||||
if (isInsecure) {
|
||||
url.protocol = 'http:';
|
||||
const {pathname} = new URL(path, 'http://example.org');
|
||||
const resourcePath = pathname.slice('/resources/'.length);
|
||||
try {
|
||||
const {buffer} = await readFile([RESOURCES_PATH, resourcePath].join('/'));
|
||||
return buffer;
|
||||
}
|
||||
catch (error) {
|
||||
if ('ENOENT' !== error.code) {
|
||||
throw error;
|
||||
}
|
||||
return new ArrayBuffer(0);
|
||||
}
|
||||
return fetch(url.href).then((response) => (
|
||||
response.ok ? response.arrayBuffer() : new ArrayBuffer(0)
|
||||
));
|
||||
}
|
||||
async readData(path) {
|
||||
const qualified = this.constructor.qualify(path);
|
||||
|
@ -75,35 +51,55 @@ class SocketServer extends Server {
|
|||
transmit(ws, packed) { ws.send(packed); }
|
||||
}
|
||||
|
||||
async function createEngine(Engine) {
|
||||
engine = new Engine(SocketServer);
|
||||
export async function handleUpgrade(request, socket, head) {
|
||||
if (!global.__silphiusWebsocket) {
|
||||
const engine = new Engine(SocketServer);
|
||||
await engine.load();
|
||||
engine.start();
|
||||
return engine;
|
||||
const handleConnection = async (ws, request) => {
|
||||
ws.on('close', async () => {
|
||||
await engine.disconnectPlayer(ws);
|
||||
})
|
||||
ws.on('message', (packed) => {
|
||||
engine.server.accept(ws, new DataView(packed.buffer, packed.byteOffset, packed.length));
|
||||
});
|
||||
const session = await getSession(request.headers['cookie']);
|
||||
await engine.connectPlayer(ws, session.get('id'));
|
||||
};
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
});
|
||||
wss.on('connection', handleConnection);
|
||||
global.__silphiusWebsocket = {engine, handleConnection, wss};
|
||||
}
|
||||
const {pathname} = new URL(request.url, 'wss://base.url');
|
||||
if (pathname === '/ws') {
|
||||
const {wss} = global.__silphiusWebsocket;
|
||||
wss.handleUpgrade(request, socket, head, function done(ws) {
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
}
|
||||
else {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function remakeServer(Engine) {
|
||||
if (onConnect) {
|
||||
wss.off('connection', onConnect);
|
||||
}
|
||||
if (engine) {
|
||||
for (const [connection] of engine.connectedPlayers) {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
createOnConnect(await createEngine(Engine));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await remakeServer(Engine);
|
||||
})();
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept('./engine.js', async ({default: Engine}) => {
|
||||
await remakeServer(Engine);
|
||||
import.meta.hot.on('vite:beforeUpdate', async () => {
|
||||
if (global.__silphiusWebsocket) {
|
||||
const {engine, handleConnection, wss} = global.__silphiusWebsocket;
|
||||
wss.off('connection', handleConnection);
|
||||
const connections = [];
|
||||
for (const [connection] of engine.connectedPlayers) {
|
||||
engine.server.send(connection, {type: 'EcsChange'});
|
||||
connections.push(connection);
|
||||
}
|
||||
await engine.stop();
|
||||
for (const connection of connections) {
|
||||
connection.close();
|
||||
}
|
||||
global.__silphiusWebsocket = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default async function listen(server) {
|
||||
server.on('upgrade', onUpgrade);
|
||||
import.meta.hot.accept();
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import {del, get, set} from 'idb-keyval';
|
|||
import {encode} from '@/net/packets/index.js';
|
||||
import Server from '@/net/server.js';
|
||||
import {withResolvers} from '@/util/promise.js';
|
||||
import {readAsset} from '@/util/resources.js';
|
||||
|
||||
import createEcs from './create/ecs.js';
|
||||
import './create/forest.js';
|
||||
|
@ -21,9 +22,10 @@ class WorkerServer extends Server {
|
|||
return ['UNIVERSE', path].join('/');
|
||||
}
|
||||
async readAsset(path) {
|
||||
return fetch(path).then((response) => (
|
||||
response.ok ? response.arrayBuffer() : new ArrayBuffer(0)
|
||||
));
|
||||
const resource = await readAsset(path);
|
||||
return resource
|
||||
? resource
|
||||
: new ArrayBuffer(0);
|
||||
}
|
||||
async readData(path) {
|
||||
const data = await get(this.constructor.qualify(path));
|
||||
|
@ -66,7 +68,19 @@ onmessage = async (event) => {
|
|||
if (import.meta.hot) {
|
||||
const before = withResolvers();
|
||||
const promises = [before.promise];
|
||||
import.meta.hot.on('vite:beforeUpdate', async () => {
|
||||
const accepted = [
|
||||
'/app/server/engine.js',
|
||||
'/app/server/create/player.js',
|
||||
'/app/server/create/forest.js',
|
||||
'/app/server/create/town.js',
|
||||
'/app/server/create/homestead.js',
|
||||
];
|
||||
let isAccepted = false;
|
||||
import.meta.hot.on('vite:beforeUpdate', async ({updates}) => {
|
||||
isAccepted = !!updates.find(({acceptedPath}) => accepted.includes(acceptedPath));
|
||||
if (!isAccepted) {
|
||||
return;
|
||||
}
|
||||
await engine.disconnectPlayer(0);
|
||||
engine.stop();
|
||||
await engine.saveEcses();
|
||||
|
@ -126,8 +140,12 @@ import.meta.hot.accept('./create/town.js', async ({default: createTown}) => {
|
|||
resolve();
|
||||
});
|
||||
import.meta.hot.on('vite:afterUpdate', async () => {
|
||||
if (!isAccepted) {
|
||||
return;
|
||||
}
|
||||
await Promise.all(promises);
|
||||
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
|
||||
postMessage(encode({type: 'EcsChange'}));
|
||||
close();
|
||||
});
|
||||
import.meta.hot.accept();
|
||||
|
|
23
app/util/inventory.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
export function distribute(item, qtys) {
|
||||
const {maximumStack} = item;
|
||||
qtys = [...qtys];
|
||||
const eligible = qtys.map((_, i) => i);
|
||||
let i = 0;
|
||||
while (item.qty > 0) {
|
||||
const key = eligible[i];
|
||||
if (qtys[key] < maximumStack) {
|
||||
qtys[key] += 1;
|
||||
i = (i + 1) % eligible.length;
|
||||
item.qty -= 1;
|
||||
}
|
||||
else {
|
||||
eligible.splice(i, 1);
|
||||
i = i % eligible.length;
|
||||
if (0 === eligible.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return qtys;
|
||||
}
|
||||
|
19
app/util/inventory.test.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {expect, test} from 'vitest';
|
||||
|
||||
import {distribute} from './inventory.js';
|
||||
|
||||
test('distributes', async () => {
|
||||
let item;
|
||||
item = {maximumStack: 20, qty: 10};
|
||||
expect(distribute(
|
||||
item,
|
||||
[15, 15, 0],
|
||||
)).to.deep.equal([19, 18, 3]);
|
||||
expect(item.qty).to.equal(0);
|
||||
item = {maximumStack: 20, qty: 20};
|
||||
expect(distribute(
|
||||
item,
|
||||
[15, 15, 0],
|
||||
)).to.deep.equal([20, 20, 10]);
|
||||
expect(item.qty).to.equal(0);
|
||||
});
|
78
app/util/resources.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
import {LRUCache} from 'lru-cache';
|
||||
|
||||
const cache = new LRUCache({
|
||||
max: 128,
|
||||
});
|
||||
|
||||
export async function computeMissing(current, manifest) {
|
||||
const missing = [];
|
||||
for (const path in manifest) {
|
||||
if (!current || !current[path] || current[path].hash !== manifest[path]) {
|
||||
missing.push(path);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
const octets = [];
|
||||
for (let i = 0; i < 256; ++i) {
|
||||
octets.push(i.toString(16).padStart(2, '0'));
|
||||
}
|
||||
|
||||
export async function parseResources(resources, buffer) {
|
||||
const view = new DataView(buffer);
|
||||
let caret = 0;
|
||||
const count = view.getUint32(caret);
|
||||
caret += 4;
|
||||
const manifest = {};
|
||||
for (let i = 0; i < count; ++i) {
|
||||
let hash = '';
|
||||
for (let j = 0; j < 20; ++j) {
|
||||
hash += octets[view.getUint8(caret)]
|
||||
caret += 1;
|
||||
}
|
||||
const byteLength = view.getUint32(caret);
|
||||
caret += 4;
|
||||
const asset = new ArrayBuffer(byteLength);
|
||||
const assetView = new DataView(asset);
|
||||
for (let j = 0; j < asset.byteLength; ++j) {
|
||||
assetView.setUint8(j, view.getUint8(caret));
|
||||
caret += 1;
|
||||
}
|
||||
manifest[resources[i]] = {asset, hash};
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export async function fetchResources(paths, {signal} = {}) {
|
||||
const response = await fetch('/resources/stream', {
|
||||
body: JSON.stringify(paths),
|
||||
method: 'post',
|
||||
signal,
|
||||
});
|
||||
if (signal.aborted) {
|
||||
return undefined;
|
||||
}
|
||||
return parseResources(paths, await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export async function get() {
|
||||
const {get} = await import('idb-keyval');
|
||||
const resources = await get('$$silphius_resources');
|
||||
return resources || {};
|
||||
}
|
||||
|
||||
export async function set(resources) {
|
||||
const {set} = await import('idb-keyval');
|
||||
cache.clear();
|
||||
await set('$$silphius_resources', resources);
|
||||
}
|
||||
|
||||
export async function readAsset(path) {
|
||||
if (!cache.has(path)) {
|
||||
const {pathname} = new URL(path, 'http://example.org');
|
||||
const resourcePath = pathname.slice('/resources/'.length);
|
||||
cache.set(path, get().then((resources) => resources[resourcePath]?.asset));
|
||||
}
|
||||
return cache.get(path);
|
||||
}
|
114
app/util/resources.server.js
Normal file
|
@ -0,0 +1,114 @@
|
|||
import {createHash} from 'node:crypto';
|
||||
import {readFile, realpath} from 'node:fs/promises';
|
||||
|
||||
import chokidar from 'chokidar';
|
||||
import {glob} from 'glob';
|
||||
|
||||
import {singleton} from './singleton.js';
|
||||
|
||||
const {
|
||||
RESOURCES_PATH = [process.cwd(), 'resources'].join('/'),
|
||||
} = process.env;
|
||||
|
||||
const resources = singleton('resources', {});
|
||||
const manifest = singleton('manifest', {});
|
||||
const RESOURCES_PATH_REAL = await realpath(RESOURCES_PATH);
|
||||
const RESOURCES_GLOB = [RESOURCES_PATH_REAL, '**', '*'].join('/');
|
||||
|
||||
async function computeAsset(path) {
|
||||
const buffer = await readFile(path);
|
||||
return {
|
||||
asset: buffer,
|
||||
hash: createHash('sha1').update(buffer).digest(),
|
||||
path: path.slice(RESOURCES_PATH_REAL.length + 1),
|
||||
};
|
||||
}
|
||||
|
||||
async function assetPaths() {
|
||||
return glob(RESOURCES_GLOB, {nodir: true});
|
||||
}
|
||||
|
||||
export async function createManifest() {
|
||||
const paths = await assetPaths();
|
||||
const entries = await Promise.all(paths.map(async (path) => {
|
||||
const asset = await computeAsset(path);
|
||||
return [asset.path, asset.hash.toString('hex')];
|
||||
}))
|
||||
return Object.fromEntries(
|
||||
entries.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadManifest() {
|
||||
emptyCheck: {
|
||||
for (const path in manifest) { // eslint-disable-line no-unused-vars
|
||||
break emptyCheck;
|
||||
}
|
||||
const created = await createManifest();
|
||||
for (const path in created) {
|
||||
manifest[path] = created[path];
|
||||
}
|
||||
const watcher = chokidar.watch(RESOURCES_GLOB);
|
||||
watcher.on('add', async (path) => {
|
||||
const asset = await computeAsset(path);
|
||||
manifest[asset.path] = asset.hash.toString('hex');
|
||||
resources[asset.path] = {asset: asset.asset.buffer, hash: asset.hash.buffer};
|
||||
});
|
||||
watcher.on('change', async (path) => {
|
||||
const asset = await computeAsset(path);
|
||||
manifest[asset.path] = asset.hash.toString('hex');
|
||||
resources[asset.path] = {asset: asset.asset.buffer, hash: asset.hash.buffer};
|
||||
});
|
||||
watcher.on('unlink', async (path) => {
|
||||
const assetPath = path.slice(RESOURCES_PATH_REAL.length + 1);
|
||||
delete resources[assetPath];
|
||||
delete manifest[assetPath];
|
||||
});
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export async function encodeResources(resources) {
|
||||
let byteLength = 4;
|
||||
for (const {asset} of resources) {
|
||||
byteLength += 20;
|
||||
byteLength += 4;
|
||||
byteLength += asset.byteLength;
|
||||
}
|
||||
const buffer = new ArrayBuffer(byteLength);
|
||||
const view = new DataView(buffer);
|
||||
let caret = 0;
|
||||
view.setUint32(caret, resources.length);
|
||||
caret += 4;
|
||||
for (const {asset, hash} of resources) {
|
||||
const hashView = new DataView(hash);
|
||||
for (let j = 0; j < 20; ++j) {
|
||||
view.setUint8(caret, hashView.getUint8(j));
|
||||
caret += 1;
|
||||
}
|
||||
view.setUint32(caret, asset.byteLength);
|
||||
caret += 4;
|
||||
const assetView = new DataView(asset);
|
||||
for (let j = 0; j < asset.byteLength; ++j) {
|
||||
view.setUint8(caret, assetView.getUint8(j));
|
||||
caret += 1;
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function loadResources() {
|
||||
emptyCheck: {
|
||||
for (const path in resources) { // eslint-disable-line no-unused-vars
|
||||
break emptyCheck;
|
||||
}
|
||||
const paths = await assetPaths();
|
||||
await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const {asset, hash, path: assetPath} = await computeAsset(path);
|
||||
resources[assetPath] = {asset: asset.buffer, hash: hash.buffer};
|
||||
}),
|
||||
);
|
||||
}
|
||||
return resources;
|
||||
}
|
45
app/util/resources.test.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
import {expect, test} from 'vitest';
|
||||
|
||||
import {computeMissing, parseResources} from './resources.js';
|
||||
import {encodeResources} from './resources.server.js';
|
||||
|
||||
test('cache', async () => {
|
||||
class TestCache {
|
||||
current;
|
||||
async get() {
|
||||
return new Promise((resolve) => {
|
||||
resolve(this.current);
|
||||
});
|
||||
}
|
||||
async set(manifest) {
|
||||
return new Promise((resolve) => {
|
||||
this.current = manifest;
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
const cache = new TestCache();
|
||||
expect(await computeMissing(await cache.get(), {foo: 'bar'})).to.deep.equal(['foo']);
|
||||
await cache.set({foo: {hash: 'bar'}});
|
||||
expect(await computeMissing(await cache.get(), {foo: 'bar'})).to.deep.equal([]);
|
||||
expect(await computeMissing(await cache.get(), {foo: 'bar', baz: '32'})).to.deep.equal(['baz']);
|
||||
await cache.set({foo: {hash: 'bar'}, baz: '32'});
|
||||
expect(await computeMissing(await cache.get(), {foo: 'bar', baz: '64'})).to.deep.equal(['baz']);
|
||||
});
|
||||
|
||||
test('codec', async () => {
|
||||
const asset = {
|
||||
asset: (new Uint8Array('hello'.split('').map((letter) => letter.charCodeAt(0)))).buffer,
|
||||
hash: (new Uint8Array(Array(20).fill(0).map((_, i) => i))).buffer,
|
||||
};
|
||||
const buffer = await encodeResources([
|
||||
asset,
|
||||
]);
|
||||
const parsed = await parseResources(['hello.txt'], buffer);
|
||||
expect('hello.txt' in parsed).to.equal(true);
|
||||
expect(parsed['hello.txt'].hash).to.equal('000102030405060708090a0b0c0d0e0f10111213');
|
||||
const hello = new Uint8Array(parsed['hello.txt'].asset);
|
||||
for (let i = 0; i < 5; ++i) {
|
||||
expect(hello[i]).to.equal('hello'.charCodeAt(i));
|
||||
}
|
||||
});
|
9
app/util/singleton.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
export function singleton(key, value) {
|
||||
global.__singletons ??= {};
|
||||
global.__singletons[key] ??= value;
|
||||
return global.__singletons[key];
|
||||
}
|
||||
|
||||
singleton.reset = function (key) {
|
||||
delete global.__singletons[key];
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"icon": "/assets/brush/brush.png",
|
||||
"label": "Brush",
|
||||
"start": "/assets/brush/start.js"
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"icon": "/assets/furball/furball.png",
|
||||
"label": "Fur Ball"
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"icon": "/assets/hoe/icon.png",
|
||||
"label": "Hoe",
|
||||
"projectionCheck": "/assets/hoe/projection-check.js",
|
||||
"projection": {
|
||||
"distance": [3, -1],
|
||||
"grid": [
|
||||
[1, 1, 1],
|
||||
[1, 1, 1],
|
||||
[1, 1, 1]
|
||||
]
|
||||
},
|
||||
"start": "/assets/hoe/start.js"
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"icon": "/assets/magic-swords/icon.png",
|
||||
"label": "Magic swords",
|
||||
"start": "/assets/magic-swords/start.js"
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"icon": "/assets/potion/icon.png",
|
||||
"label": "Potion",
|
||||
"start": "/assets/potion/start.js"
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
wielder.Health.health += 10
|
||||
item.qty -= 1
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"icon": "/assets/tomato-seeds/icon.png",
|
||||
"label": "Tomato Seeds",
|
||||
"projection": {
|
||||
"distance": [1, -1],
|
||||
"grid": [
|
||||
[1, 1, 1],
|
||||
[1, 1, 1],
|
||||
[1, 1, 1]
|
||||
]
|
||||
},
|
||||
"projectionCheck": "/assets/tomato-seeds/projection-check.js",
|
||||
"start": "/assets/tomato-seeds/start.js"
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"icon": "/assets/tomato/tomato.png"
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"icon": "/assets/watering-can/icon.png",
|
||||
"label": "Watering Can",
|
||||
"projectionCheck": "/assets/watering-can/projection-check.js",
|
||||
"projection": {
|
||||
"distance": [3, -1],
|
||||
"grid": [
|
||||
[1, 1, 1],
|
||||
[1, 1, 1],
|
||||
[1, 1, 1]
|
||||
]
|
||||
},
|
||||
"start": "/assets/watering-can/start.js"
|
||||
}
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
6
resources/brush/brush.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"icon": "/resources/brush/brush.png",
|
||||
"label": "Brush",
|
||||
"price": 100,
|
||||
"start": "/resources/brush/start.js"
|
||||
}
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
@ -6,7 +6,7 @@ for (const entity of entities) {
|
|||
Controlled.locked = 1
|
||||
const [, direction] = Sprite.animation.split(':')
|
||||
for (let i = 0; i < 2; ++i) {
|
||||
Sound.play('/assets/brush/brush.wav');
|
||||
Sound.play('/resources/brush/brush.wav');
|
||||
Sprite.animation = ['moving', direction].join(':');
|
||||
await wait(0.3)
|
||||
Sprite.animation = ['idle', direction].join(':');
|
||||
|
@ -14,7 +14,7 @@ for (const entity of entities) {
|
|||
}
|
||||
Inventory.give({
|
||||
qty: 1,
|
||||
source: '/assets/furball/furball.json',
|
||||
source: '/resources/furball/furball.json',
|
||||
});
|
||||
Controlled.locked = 0;
|
||||
|
||||
|
@ -69,7 +69,7 @@ for (const entity of entities) {
|
|||
{
|
||||
type: 'textureSingle',
|
||||
config: {
|
||||
texture: '/assets/heart/heart.png',
|
||||
texture: '/resources/heart/heart.png',
|
||||
}
|
||||
},
|
||||
],
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |