Compare commits

...

45 Commits

Author SHA1 Message Date
cha0s
6b60877711 fun: offsets 2024-10-02 18:01:35 -05:00
cha0s
1b35b03eb1 fix: ui ratio 2024-10-02 17:49:25 -05:00
cha0s
2204c2cdbf chore: -sec for now 2024-10-02 17:49:13 -05:00
cha0s
066abec937 perf: memo 2024-10-02 15:12:14 -05:00
cha0s
0639c825a9 refactor: resource cache 2024-10-02 14:39:56 -05:00
cha0s
3bbaf83140 fix: trading and usage when inventory open 2024-10-02 14:29:44 -05:00
cha0s
85e252e423 refactor: distribution 2024-09-29 19:10:49 -05:00
cha0s
0bfd4b3a9f refactor: rendered distributing 2024-09-29 06:43:58 -05:00
cha0s
9e82b3a5c1 fix: healing too 2024-09-29 06:32:13 -05:00
cha0s
0cdabd0858 refactor: distribution 2024-09-29 06:32:06 -05:00
cha0s
fec717cbe1 refactor: distribute to destination 2024-09-29 06:31:30 -05:00
cha0s
1c88b32bad feat: healing 2024-09-29 06:31:14 -05:00
cha0s
889498b243 fix: hue 2024-09-29 06:30:55 -05:00
cha0s
9d9f94a7cc feat: inventory ops 2024-09-29 05:25:54 -05:00
cha0s
997ef691ca fix: inventory self-swapping 2024-09-29 02:19:37 -05:00
cha0s
0cb5cdbe1f fix: qty sizing 2024-09-29 02:05:06 -05:00
cha0s
46cff307b1 fix: prediction 2024-09-29 01:53:23 -05:00
cha0s
e6f54c3694 refactor: mergeDiff API 2024-09-29 01:52:35 -05:00
cha0s
f328ce4ccb dev: better crash report 2024-09-28 11:27:44 -05:00
cha0s
b5019153f3 fix: atomic tomatoes 2024-09-28 10:26:20 -05:00
cha0s
0588dbf6c2 fix: resource updates 2024-09-28 10:22:16 -05:00
cha0s
da51111106 fix: des loop 2024-09-28 08:03:52 -05:00
cha0s
ffa391fcef refactor: creation 2024-09-28 07:56:12 -05:00
cha0s
c1bdae1c8c fix: give 2024-09-28 07:56:00 -05:00
cha0s
46f0a0cc07 fix: des dep promise count check 2024-09-28 07:24:25 -05:00
cha0s
92c85e57d3 fix: qty update 2024-09-27 11:09:56 -05:00
cha0s
493042b913 fix: inventory 2024-09-27 09:06:14 -05:00
cha0s
f673833a1d feat: shop 2024-09-27 07:45:13 -05:00
cha0s
512c5a470a refactor: hmr 2024-09-24 05:33:08 -05:00
cha0s
39e7082a28 fix: race 2024-09-24 05:32:43 -05:00
cha0s
3106788ca7 feat: datetime 2024-09-23 00:42:19 -05:00
cha0s
55f915d4a1 fun: less animules 2024-09-22 02:42:29 -05:00
cha0s
f1d3ad6a6d perf: entity 2024-09-22 02:32:34 -05:00
cha0s
73b7a9e0a5 perf: updates 2024-09-22 02:16:32 -05:00
cha0s
5492ec32bd fix: external bag race condition 2024-09-21 20:36:03 -05:00
cha0s
9d176c2930 refactor: resources 2024-09-21 06:24:26 -05:00
cha0s
b20795137e chore: junk 2024-09-21 04:44:22 -05:00
cha0s
749a3356c3 refactor: gen 2024-09-19 21:35:02 -05:00
cha0s
6a45622b9d fix: styles 2024-09-18 17:46:36 -05:00
cha0s
e2c0ec7638 fix: HMR 2024-09-18 01:32:39 -05:00
cha0s
946a06e78a fix: build serve 2024-09-17 17:48:12 -05:00
cha0s
c24adc47a8 refactor: resources 2024-09-17 01:25:39 -05:00
cha0s
b137b91ced refactor: create data 2024-09-17 01:04:33 -05:00
cha0s
acaa930fe1 fix: render normals first 2024-09-17 01:01:32 -05:00
cha0s
cd8a933a5b dev: initial gen ui 2024-09-17 01:01:20 -05:00
155 changed files with 2405 additions and 604 deletions

View File

@ -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,

View File

@ -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;

View File

@ -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'},

View File

@ -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]]);
Component.markChange(OtherInventory.entity, 'swapped', [[r, this.entity, l]]);
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'},
},
},

View File

@ -0,0 +1,3 @@
import Component from '@/ecs/component.js';
export default class Shop extends Component {}

View File

@ -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);
}

View File

@ -0,0 +1,7 @@
import Component from '@/ecs/component.js';
export default class Wallet extends Component {
static properties = {
gold: {type: 'uint32'},
};
}

View File

@ -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,14 +397,18 @@ export default class Ecs {
}
// Otherwise, merge.
else {
for (const componentName in components) {
this.diff[entityId][componentName] = false === components[componentName]
? false
: this.Components[componentName].mergeDiff(
this.diff[entityId][componentName] || {},
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(
l[componentName] || {},
r[componentName],
);
}
}

View File

@ -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, {

View File

@ -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);
}
}

View 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;

View File

@ -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>

View File

@ -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,
),
);

View 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}
&nbsp;
{dateLabel}
</div>
);
}
function ConnectedDate() {
const [season, setSeason] = useState(0);
const [date, setDate] = useState(0);
return <Date season={season} date={date} />;
}
export default ConnectedDate;

View 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;
}

View 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;

View 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: ', ';
}
}

View File

@ -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})
`,
}}
>

View File

@ -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);

View File

@ -1,7 +1,3 @@
.external {
left: 20px;
opacity: 1;
position: absolute;
top: 274px;
transition: top 150ms, opacity 200ms;
--nothing: 0;
}

View File

@ -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>

View File

@ -43,3 +43,7 @@
z-index: 1;
}
}
.highlighted {
--nothing: 0;
}

View File

@ -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>
);

View File

@ -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(' ')
}
>

View File

@ -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
;
}
}

View 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;

View 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;
}

View 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&apos;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;

View 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%;
}

View 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;

View 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;
}

View File

@ -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>
</>
)
}

View File

@ -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,
);
diffuse.texture = texture;
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,
);
normals.texture = texture;
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) {

View File

@ -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}
@ -67,13 +65,16 @@ export default function Pixi({camera, monopolizers, particleWorker, scale}) {
background: 0x0,
}}
>
<Ecs
camera={camera}
monopolizers={monopolizers}
particleWorker={particleWorker}
scale={scale}
/>
<Container
ref={ref}
>
<Ecs
monopolizers={monopolizers}
particleWorker={particleWorker}
/>
</Container>
</Stage>
);
}
export default memo(forwardRef(Pixi));

View File

@ -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}
/>
</>

View File

@ -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 () => {
await ecsRef.current.apply(payload.ecs);
client.emitter.invoke(':Ecs', payload.ecs);
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]},
});
}
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, 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: 'swapSlots', value: [0, mainEntityRef.current, i + 1]},
payload: {type: 'acceptTrade', value: {gaining, losing}},
});
}, [client, keepHotbarOpen, mainEntityRef]);
const bagOnActivate = useCallback((i) => {
client.send({
type: 'Action',
payload: {type: 'swapSlots', value: [0, mainEntityRef.current, i + 11]},
});
}, [client, mainEntityRef]);
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 && (
<External
isInventoryOpen={isInventoryOpen}
onActivate={(i) => {
client.send({
type: 'Action',
payload: {type: 'swapSlots', value: [0, externalInventory, i]},
});
}}
slots={externalInventorySlots}
/>
<div className={styles.external}>
<External
highlighted={trading && gaining}
isInventoryOpen={isInventoryOpen}
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 && (

View File

@ -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%;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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>
<Ui disconnected={disconnected} />
</AssetsContext.Provider>
</DebugContext.Provider>
</EcsContext.Provider>

View File

@ -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;

View File

@ -0,0 +1,10 @@
import styles from './layer.module.css';
function Layer() {
return (
<>
</>
);
}
export default Layer;

View 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;

View 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;
}
}

View 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;

View 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;

View 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);
}

View File

@ -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: {},
});

View File

@ -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,186 +143,237 @@ 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({
...animalJson,
Behaving: {
routines: {
initial: '/assets/kitty/initial.js',
return {
...animalJson,
Behaving: {
routines: {
initial: '/resources/kitty/initial.js',
},
},
Collider: {
bodies: [
{
points: [
{x: -3.5, y: -3.5},
{x: 3.5, y: -3.5},
{x: 3.5, y: 3.5},
{x: -3.5, y: 3.5},
],
},
],
},
Interactive: {
interacting: 1,
interactScript: `
const lines = [
'mrowwr',
'p<shake>rrr</shake>o<wave>wwwww</wave>',
'mew<rate frequency={0.5}> </rate>mew!',
'me<wave>wwwww</wave>',
'\\\\*pu<shake>rrrrr</shake>\\\\*',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
`,
},
Position: {
x: 250 + (Math.random() - 0.5) * 300,
y: 250 + (Math.random() - 0.5) * 300,
},
Sprite: {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.7,
source: '/resources/kitty/kitty.json',
speed: 0.115,
},
Tags: {tags: ['kittan']},
};
}
function createTestCow() {
const animalJson = animal();
return {
...animalJson,
Behaving: {
routines: {
initial: '/resources/farm/animals/cow-adult/initial.js',
},
Collider: {
bodies: [
{
points: [
{x: -3.5, y: -3.5},
{x: 3.5, y: -3.5},
{x: 3.5, y: 3.5},
{x: -3.5, y: 3.5},
],
},
],
},
Interactive: {
interacting: 1,
interactScript: `
const lines = [
'mrowwr',
'p<shake>rrr</shake>o<wave>wwwww</wave>',
'mew<rate frequency={0.5}> </rate>mew!',
'me<wave>wwwww</wave>',
'\\\\*pu<shake>rrrrr</shake>\\\\*',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
`,
},
Position: {
x: 250 + (Math.random() - 0.5) * 300,
y: 250 + (Math.random() - 0.5) * 300,
},
Sprite: {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.7,
source: '/assets/kitty/kitty.json',
speed: 0.115,
},
Tags: {tags: ['kittan']},
});
}
for (let i = 0; i < 50; ++i) {
entities.push({
...animalJson,
Behaving: {
routines: {
initial: '/assets/farm/animals/cow-adult/initial.js',
},
Collider: {
bodies: [
{
points: [
{x: -3.5, y: -3.5},
{x: 3.5, y: -3.5},
{x: 3.5, y: 3.5},
{x: -3.5, y: 3.5},
],
},
],
},
Interactive: {
interacting: 1,
interactScript: `
const lines = [
'sno<shake>rr</shake>t',
'm<wave>ooooooooooo</wave>',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
`,
},
Position: {
x: 350 + (Math.random() - 0.5) * 300,
y: 350 + (Math.random() - 0.5) * 300,
},
Sprite: {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.8,
source: '/resources/farm/animals/cow-adult/cow-adult.json',
speed: 0.25,
},
};
}
function createTestGoat() {
const animalJson = animal();
return {
...animalJson,
Behaving: {
routines: {
initial: '/resources/farm/animals/goat-white/initial.js',
},
Collider: {
bodies: [
{
points: [
{x: -3.5, y: -3.5},
{x: 3.5, y: -3.5},
{x: 3.5, y: 3.5},
{x: -3.5, y: 3.5},
],
},
],
},
Interactive: {
interacting: 1,
interactScript: `
const lines = [
'sno<shake>rr</shake>t',
'm<wave>ooooooooooo</wave>',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
`,
},
Position: {
x: 350 + (Math.random() - 0.5) * 300,
y: 350 + (Math.random() - 0.5) * 300,
},
Sprite: {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.8,
source: '/assets/farm/animals/cow-adult/cow-adult.json',
speed: 0.25,
},
});
}
for (let i = 0; i < 50; ++i) {
entities.push({
...animalJson,
Behaving: {
routines: {
initial: '/assets/farm/animals/goat-white/initial.js',
},
Collider: {
bodies: [
{
points: [
{x: -7, y: -3.5},
{x: 7, y: -3.5},
{x: 7, y: 3.5},
{x: -7, y: 3.5},
],
},
},
Collider: {
bodies: [
{
points: [
{x: -7, y: -3.5},
{x: 7, y: -3.5},
{x: 7, y: 3.5},
{x: -7, y: 3.5},
],
},
],
},
Interactive: {
interacting: 1,
interactScript: `
const lines = [
'Mind your own business, buddy.\\n\\ner, I mean, <shake>MEEHHHHHH</shake>',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
`,
},
Position: {
x: 350 + (Math.random() - 0.5) * 300,
y: 150 + (Math.random() - 0.5) * 300,
},
Sprite: {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.8,
source: '/assets/farm/animals/goat-white/goat-white.json',
speed: 0.25,
},
});
}
entities.push({
],
},
Interactive: {
interacting: 1,
interactScript: `
const lines = [
'Mind your own business, buddy.\\n\\ner, I mean, <shake>MEEHHHHHH</shake>',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
`,
},
Position: {
x: 350 + (Math.random() - 0.5) * 300,
y: 150 + (Math.random() - 0.5) * 300,
},
Sprite: {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.8,
source: '/resources/farm/animals/goat-white/goat-white.json',
speed: 0.25,
},
};
}
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;
}

View File

@ -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},
}
],

View File

@ -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,
},

View File

@ -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},
},
],

View File

@ -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;
}

View File

@ -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);
await engine.load();
engine.start();
return engine;
}
async function remakeServer(Engine) {
if (onConnect) {
wss.off('connection', onConnect);
export async function handleUpgrade(request, socket, head) {
if (!global.__silphiusWebsocket) {
const engine = new Engine(SocketServer);
await engine.load();
engine.start();
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};
}
if (engine) {
for (const [connection] of engine.connectedPlayers) {
connection.close();
}
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();
}
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();
}

View File

@ -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
View 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;
}

View 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
View 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);
}

View 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;
}

View 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
View 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];
}

View File

@ -1,5 +0,0 @@
{
"icon": "/assets/brush/brush.png",
"label": "Brush",
"start": "/assets/brush/start.js"
}

View File

@ -1,4 +0,0 @@
{
"icon": "/assets/furball/furball.png",
"label": "Fur Ball"
}

View File

@ -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"
}

View File

@ -1,5 +0,0 @@
{
"icon": "/assets/magic-swords/icon.png",
"label": "Magic swords",
"start": "/assets/magic-swords/start.js"
}

View File

@ -1,5 +0,0 @@
{
"icon": "/assets/potion/icon.png",
"label": "Potion",
"start": "/assets/potion/start.js"
}

View File

@ -1,2 +0,0 @@
wielder.Health.health += 10
item.qty -= 1

View File

@ -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"
}

View File

@ -1,3 +0,0 @@
{
"icon": "/assets/tomato/tomato.png"
}

View File

@ -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"
}

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,6 @@
{
"icon": "/resources/brush/brush.png",
"label": "Brush",
"price": 100,
"start": "/resources/brush/start.js"
}

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -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',
}
},
],

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Some files were not shown because too many files have changed in this diff Show More