Compare commits

...

9 Commits

Author SHA1 Message Date
cha0s
3d862d39d5 feat: switch ecs 2024-07-03 11:17:36 -05:00
cha0s
5fb34d66ac fix: no water 2024-07-02 22:43:23 -05:00
cha0s
30033fd8e4 feat: collision scripts 2024-07-02 22:42:56 -05:00
cha0s
86a3367efa fix: avoid work 2024-07-02 21:25:52 -05:00
cha0s
7fdc7ba4e9 fix: double start 2024-07-02 21:25:45 -05:00
cha0s
e2cad034c2 perf: avoid work 2024-07-02 20:57:35 -05:00
cha0s
26b85f6520 feat: HMR++ 2024-07-02 20:43:55 -05:00
cha0s
b21ef309aa fix: slots change and mark 2024-07-02 20:43:47 -05:00
cha0s
41d447a6a3 feat: more controls 2024-07-02 18:20:53 -05:00
20 changed files with 573 additions and 238 deletions

37
app/client-ecs.js Normal file
View File

@ -0,0 +1,37 @@
import Ecs from '@/ecs/ecs.js';
import Script from '@/util/script.js';
import {LRUCache} from 'lru-cache';
const cache = new LRUCache({
max: 128,
});
export default class ClientEcs extends Ecs {
async readAsset(uri) {
if (!cache.has(uri)) {
let promise, resolve, reject;
promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
cache.set(uri, promise);
fetch(new URL(uri, window.location.origin))
.then(async (response) => {
resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0));
})
.catch(reject);
}
return cache.get(uri);
}
async readJson(uri) {
const chars = await this.readAsset(uri);
return chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {};
}
async readScript(uri, context = {}) {
const code = await this.readAsset(uri);
if (code.byteLength > 0) {
return Script.fromCode((new TextDecoder()).decode(code), context);
}
}
}

View File

@ -11,6 +11,6 @@ export function useEcs() {
} }
export function useEcsTick(fn, dependencies) { export function useEcsTick(fn, dependencies) {
const ecs = useEcs(); const [ecs] = useEcs();
usePacket(':Ecs', fn, [ecs, ...dependencies]); usePacket(':Ecs', fn, [ecs, ...dependencies]);
} }

29
app/create-ecs.js Normal file
View File

@ -0,0 +1,29 @@
import Components from '@/ecs-components/index.js';
import Systems from '@/ecs-systems/index.js';
export default function createEcs(Ecs) {
const ecs = new Ecs({Components, Systems});
const defaultSystems = [
'ResetForces',
'ApplyControlMovement',
'IntegratePhysics',
'ClampPositions',
'PlantGrowth',
'FollowCamera',
'VisibleAabbs',
'Colliders',
'ControlDirection',
'SpriteDirection',
'RunAnimations',
'RunTickingPromises',
'Water',
'Interactions',
];
defaultSystems.forEach((defaultSystem) => {
const System = ecs.system(defaultSystem);
if (System) {
System.active = true;
}
});
return ecs;
}

42
app/create-homestead.js Normal file
View File

@ -0,0 +1,42 @@
import createEcs from './create-ecs.js';
export default async function createHomestead(Ecs) {
const ecs = createEcs(Ecs);
const area = {x: 100, y: 60};
await ecs.create({
AreaSize: {x: area.x * 16, y: area.y * 16},
Ticking: {},
TileLayers: {
layers: [
{
area,
data: Array(area.x * area.y).fill(0).map(() => 1 + Math.floor(Math.random() * 4)),
source: '/assets/tileset.json',
tileSize: {x: 16, y: 16},
}
],
},
Water: {water: {}},
});
await ecs.create({
Collider: {
bodies: [
[
{x: -36, y: -16},
{x: -21, y: -16},
{x: -36, y: -1},
{x: -21, y: -1},
],
],
collisionStartScript: '/assets/shit-shack/collision-start.js',
},
Ecs: {},
Position: {x: 100, y: 100},
Sprite: {
anchor: {x: 0.5, y: 0.8},
source: '/assets/shit-shack/shit-shack.json',
},
VisibleAabb: {},
});
return ecs;
}

39
app/create-house.js Normal file
View File

@ -0,0 +1,39 @@
import createEcs from './create-ecs.js';
export default async function createHouse(Ecs) {
const ecs = createEcs(Ecs);
const area = {x: 20, y: 20};
await ecs.create({
AreaSize: {x: area.x * 16, y: area.y * 16},
Ticking: {},
TileLayers: {
layers: [
{
area,
data: Array(area.x * area.y).fill(0).map(() => 5 + Math.floor(Math.random() * 2)),
source: '/assets/tileset.json',
tileSize: {x: 16, y: 16},
}
],
},
});
await ecs.create({
Collider: {
bodies: [
[
{x: -8, y: -8},
{x: 7, y: -8},
{x: 7, y: 7},
{x: -8, y: 7},
],
],
collisionStartScript: '/assets/house/collision-start.js',
},
Ecs: {},
Position: {
x: 72,
y: 320,
},
});
return ecs;
}

60
app/create-player.js Normal file
View File

@ -0,0 +1,60 @@
export default async function createPlayer(id) {
const player = {
Camera: {},
Collider: {
bodies: [
[
{x: -8, y: -8},
{x: 7, y: -8},
{x: -8, y: 7},
{x: 7, y: 7},
],
],
},
Controlled: {},
Direction: {direction: 2},
Ecs: {path: ['homesteads', `${id}`].join('/')},
Emitter: {},
Forces: {},
Interacts: {},
Inventory: {
slots: {
1: {
qty: 100,
source: '/assets/potion/potion.json',
},
2: {
qty: 1,
source: '/assets/watering-can/watering-can.json',
},
3: {
qty: 1,
source: '/assets/tomato-seeds/tomato-seeds.json',
},
4: {
qty: 1,
source: '/assets/hoe/hoe.json',
},
},
},
Health: {health: 100},
Position: {x: 128, y: 128},
Speed: {speed: 100},
Sound: {},
Sprite: {
anchor: {x: 0.5, y: 0.8},
animation: 'moving:down',
frame: 0,
frames: 8,
source: '/assets/dude/dude.json',
speed: 0.115,
},
Ticking: {},
VisibleAabb: {},
Wielder: {
activeSlot: 0,
},
};
return (new TextEncoder()).encode(JSON.stringify(player));
}

View File

@ -7,6 +7,7 @@ export default class Collider extends Component {
instanceFromSchema() { instanceFromSchema() {
const {ecs} = this; const {ecs} = this;
return class ColliderInstance extends super.instanceFromSchema() { return class ColliderInstance extends super.instanceFromSchema() {
collidingWith = {};
isCollidingWith(other) { isCollidingWith(other) {
const {aabb, aabbs} = this; const {aabb, aabbs} = this;
const {aabb: otherAabb, aabbs: otherAabbs} = other; const {aabb: otherAabb, aabbs: otherAabbs} = other;
@ -63,6 +64,26 @@ export default class Collider extends Component {
} }
} }
} }
async load(instance) {
// heavy handed...
if ('undefined' !== typeof window) {
return;
}
instance.collisionEndScriptInstance = await this.ecs.readScript(
instance.collisionEndScript,
{
ecs: this.ecs,
entity: this.ecs.get(instance.entity),
},
);
instance.collisionStartScriptInstance = await this.ecs.readScript(
instance.collisionStartScript,
{
ecs: this.ecs,
entity: this.ecs.get(instance.entity),
},
);
}
static properties = { static properties = {
bodies: { bodies: {
type: 'array', type: 'array',
@ -71,5 +92,7 @@ export default class Collider extends Component {
subtype: vector2d('int16'), subtype: vector2d('int16'),
}, },
}, },
collisionEndScript: {type: 'string'},
collisionStartScript: {type: 'string'},
}; };
} }

View File

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

View File

@ -101,13 +101,13 @@ class ItemProxy {
set qty(qty) { set qty(qty) {
const {instance} = this; const {instance} = this;
if (qty <= 0) { if (qty <= 0) {
Component.markChange(instance.entity, 'cleared', {[this.slot]: true}); this.Component.markChange(instance.entity, 'cleared', {[this.slot]: true});
delete instance.slots[this.slot]; delete instance.slots[this.slot];
delete instance.$$items[this.slot]; delete instance.$$items[this.slot];
} }
else { else {
instance.slots[this.slot].qty = qty; instance.slots[this.slot].qty = qty;
Component.markChange(instance.entity, 'qtyUpdated', {[this.slot]: qty}); this.Component.markChange(instance.entity, 'qtyUpdated', {[this.slot]: qty});
} }
} }
} }
@ -148,7 +148,17 @@ export default class Inventory extends Component {
} }
} }
} }
return super.insertMany(entities); await super.insertMany(entities);
for (const [id, {slots}] of entities) {
if (slots) {
const instance = this.get(id);
instance.$$items = {};
for (const slot in slots) {
instance.$$items[slot] = new ItemProxy(this, instance, slot);
await instance.$$items[slot].load(instance.slots[slot].source);
}
}
}
} }
instanceFromSchema() { instanceFromSchema() {
const Instance = super.instanceFromSchema(); const Instance = super.instanceFromSchema();

View File

@ -39,6 +39,7 @@ export default class Colliders extends System {
} }
tick() { tick() {
const {Ticking} = this.ecs.get(1);
const seen = {}; const seen = {};
for (const entity of this.ecs.changed(['Position'])) { for (const entity of this.ecs.changed(['Position'])) {
if (seen[entity.id]) { if (seen[entity.id]) {
@ -48,6 +49,8 @@ export default class Colliders extends System {
if (!entity.Collider) { if (!entity.Collider) {
continue; continue;
} }
const {collidingWith: wasCollidingWith} = entity.Collider;
entity.Collider.collidingWith = {};
this.updateHash(entity); this.updateHash(entity);
for (const other of this.within(entity.Collider.aabb)) { for (const other of this.within(entity.Collider.aabb)) {
if (seen[other.id]) { if (seen[other.id]) {
@ -57,8 +60,33 @@ export default class Colliders extends System {
if (!other.Collider) { if (!other.Collider) {
continue; continue;
} }
delete other.Collider.collidingWith[entity.id];
if (entity.Collider.isCollidingWith(other.Collider)) { if (entity.Collider.isCollidingWith(other.Collider)) {
console.log('collide', entity, other); entity.Collider.collidingWith[other.id] = true;
other.Collider.collidingWith[entity.id] = true;
if (!wasCollidingWith[other.id]) {
if (entity.Collider.collisionStartScriptInstance) {
entity.Collider.collisionStartScriptInstance.context.other = other;
Ticking.addTickingPromise(entity.Collider.collisionStartScriptInstance.tickingPromise());
}
if (other.Collider.collisionStartScriptInstance) {
other.Collider.collisionStartScriptInstance.context.other = entity;
Ticking.addTickingPromise(other.Collider.collisionStartScriptInstance.tickingPromise());
}
}
}
}
for (const otherId in wasCollidingWith) {
if (!entity.Collider.collidingWith[otherId]) {
const other = this.ecs.get(otherId);
if (entity.Collider.collisionEndScriptInstance) {
entity.Collider.collisionEndScriptInstance.context.other = other;
Ticking.addTickingPromise(entity.Collider.collisionEndScriptInstance.tickingPromise());
}
if (other.Collider.collisionEndScriptInstance) {
other.Collider.collisionEndScriptInstance.context.other = entity;
Ticking.addTickingPromise(other.Collider.collisionEndScriptInstance.tickingPromise());
}
} }
} }
} }

View File

@ -6,10 +6,15 @@ export default class Water extends System {
tick(elapsed) { tick(elapsed) {
const {Water} = this.ecs.get(1); const {Water} = this.ecs.get(1);
if (!Water) {
return;
}
for (const tile in Water.water) { for (const tile in Water.water) {
Water.water[tile] = Math.max(0, Water.water[tile] - elapsed); Water.water[tile] = Math.max(0, Water.water[tile] - elapsed);
} }
Water.water = {...Water.water}; if (Object.keys(Water.water).length > 0) {
Water.water = {...Water.water};
}
} }
} }

View File

@ -62,10 +62,18 @@ export default class Ecs {
creating.push([entityId, componentsToUpdate]); creating.push([entityId, componentsToUpdate]);
} }
} }
this.destroyMany(destroying); if (destroying.length > 0) {
await this.insertMany(updating); this.destroyMany(destroying);
this.removeMany(removing); }
await this.createManySpecific(creating); if (updating.length > 0) {
await this.insertMany(updating);
}
if (removing.length > 0) {
this.removeMany(removing);
}
if (creating.length > 0) {
await this.createManySpecific(creating);
}
} }
changed(criteria) { changed(criteria) {
@ -111,6 +119,9 @@ export default class Ecs {
} }
async createManySpecific(specificsList) { async createManySpecific(specificsList) {
if (0 === specificsList.length) {
return;
}
const entityIds = []; const entityIds = [];
const creating = {}; const creating = {};
for (let i = 0; i < specificsList.length; i++) { for (let i = 0; i < specificsList.length; i++) {

View File

@ -3,24 +3,21 @@ import {
TPS, TPS,
} from '@/constants.js'; } from '@/constants.js';
import Ecs from '@/ecs/ecs.js'; import Ecs from '@/ecs/ecs.js';
import Components from '@/ecs-components/index.js';
import Systems from '@/ecs-systems/index.js';
import {decode, encode} from '@/packets/index.js'; import {decode, encode} from '@/packets/index.js';
import Script from '@/util/script.js'; import Script from '@/util/script.js';
function join(...parts) { import createEcs from './create-ecs.js';
return parts.join('/'); import createHomestead from './create-homestead.js';
} import createHouse from './create-house.js';
import createPlayer from './create-player.js';
export default class Engine { export default class Engine {
connections = [];
connectedPlayers = new Map(); connectedPlayers = new Map();
connectingPlayers = [];
ecses = {}; ecses = {};
frame = 0; frame = 0;
handle; handle;
incomingActions = []; incomingActions = new Map();
last = Date.now(); last = Date.now();
server; server;
@ -34,6 +31,7 @@ export default class Engine {
super.transmit(connection, encode(packet)); super.transmit(connection, encode(packet));
} }
} }
const engine = this;
const server = this.server = new SilphiusServer(); const server = this.server = new SilphiusServer();
this.Ecs = class EngineEcs extends Ecs { this.Ecs = class EngineEcs extends Ecs {
async readAsset(uri) { async readAsset(uri) {
@ -44,68 +42,110 @@ export default class Engine {
return chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {}; return chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {};
} }
async readScript(uri, context) { async readScript(uri, context) {
if (!uri) {
return undefined;
}
const code = await this.readAsset(uri); const code = await this.readAsset(uri);
if (code.byteLength > 0) { if (code.byteLength > 0) {
return Script.fromCode((new TextDecoder()).decode(code), context); return Script.fromCode((new TextDecoder()).decode(code), context);
} }
} }
async switchEcs(entity, path, updates) {
for (const [connection, connectedPlayer] of engine.connectedPlayers) {
if (entity !== connectedPlayer.entity) {
continue;
}
// remove entity link to connection to start queueing actions and pause updates
delete connectedPlayer.entity;
// forget previous state
connectedPlayer.memory.clear();
// inform client of the upcoming change
server.send(
connection,
{
type: 'EcsChange',
payload: {},
},
);
// dump entity state with updates for the transition
const dumped = {
...entity.toJSON(),
Controlled: entity.Controlled,
Ecs: {path},
...updates,
};
// remove from old ECS
this.destroy(entity.id);
// load if necessary
if (!engine.ecses[path]) {
await engine.loadEcs(path);
}
// recreate the entity in the new ECS and again associate it with the connection
connectedPlayer.entity = engine.ecses[path].get(await engine.ecses[path].create(dumped));
}
}
} }
this.server.addPacketListener('Action', (connection, payload) => { this.server.addPacketListener('Action', (connection, payload) => {
this.incomingActions.push([connection, payload]); if (!this.incomingActions.has(connection)) {
this.incomingActions.set(connection, []);
}
this.incomingActions.get(connection).push(payload);
}); });
} }
acceptActions() { acceptActions() {
for (const [ for (const [connection, payloads] of this.incomingActions) {
connection,
payload,
] of this.incomingActions) {
if (!this.connectedPlayers.get(connection)) { if (!this.connectedPlayers.get(connection)) {
continue; continue;
} }
const {entity} = this.connectedPlayers.get(connection); const {entity} = this.connectedPlayers.get(connection);
if (!entity) {
continue;
}
const {Controlled, Ecs, Interacts, Inventory, Wielder} = entity; const {Controlled, Ecs, Interacts, Inventory, Wielder} = entity;
switch (payload.type) { for (const payload of payloads) {
case 'changeSlot': { switch (payload.type) {
if (!Controlled.locked) { case 'changeSlot': {
Wielder.activeSlot = payload.value - 1; if (!Controlled.locked) {
Wielder.activeSlot = payload.value - 1;
}
break;
} }
break; case 'moveUp':
} case 'moveRight':
case 'moveUp': case 'moveDown':
case 'moveRight': case 'moveLeft': {
case 'moveDown': Controlled[payload.type] = payload.value;
case 'moveLeft': { break;
Controlled[payload.type] = payload.value;
break;
}
case 'swapSlots': {
if (!Controlled.locked) {
Inventory.swapSlots(...payload.value);
} }
break; case 'swapSlots': {
} if (!Controlled.locked) {
case 'use': { Inventory.swapSlots(...payload.value);
if (!Controlled.locked) { }
Wielder.useActiveItem(payload.value); break;
} }
break; case 'use': {
} if (!Controlled.locked) {
case 'interact': { Wielder.useActiveItem(payload.value);
if (!Controlled.locked) { }
if (payload.value) { break;
if (Interacts.willInteractWith) { }
const ecs = this.ecses[Ecs.path]; case 'interact': {
const subject = ecs.get(Interacts.willInteractWith); if (!Controlled.locked) {
subject.Interactive.interact(entity); if (payload.value) {
if (Interacts.willInteractWith) {
const ecs = this.ecses[Ecs.path];
const subject = ecs.get(Interacts.willInteractWith);
subject.Interactive.interact(entity);
}
} }
} }
break;
} }
break;
} }
} }
this.incomingActions.set(connection, []);
} }
this.incomingActions = [];
} }
async connectPlayer(connection, id) { async connectPlayer(connection, id) {
@ -115,7 +155,6 @@ export default class Engine {
} }
const ecs = this.ecses[entityJson.Ecs.path]; const ecs = this.ecses[entityJson.Ecs.path];
const entity = await ecs.create(entityJson); const entity = await ecs.create(entityJson);
this.connections.push(connection);
this.connectedPlayers.set( this.connectedPlayers.set(
connection, connection,
{ {
@ -126,123 +165,17 @@ export default class Engine {
); );
} }
createEcs() {
return new this.Ecs({Components, Systems});
}
async createHomestead(id) {
const ecs = this.createEcs();
const area = {x: 100, y: 60};
await ecs.create({
AreaSize: {x: area.x * 16, y: area.y * 16},
Engine: {},
TileLayers: {
layers: [
{
area,
data: Array(area.x * area.y).fill(0).map(() => 1 + Math.floor(Math.random() * 4)),
source: '/assets/tileset.json',
tileSize: {x: 16, y: 16},
}
],
},
Water: {water: {}},
});
await ecs.create({
Position: {x: 100, y: 100},
Sprite: {
anchor: {x: 0.5, y: 0.8},
source: '/assets/shit-shack/shit-shack.json',
},
VisibleAabb: {},
});
const defaultSystems = [
'ResetForces',
'ApplyControlMovement',
'IntegratePhysics',
'ClampPositions',
'PlantGrowth',
'FollowCamera',
'VisibleAabbs',
'Collliders',
'ControlDirection',
'SpriteDirection',
'RunAnimations',
'RunTickingPromises',
'Water',
'Interactions',
];
defaultSystems.forEach((defaultSystem) => {
const System = ecs.system(defaultSystem);
if (System) {
System.active = true;
}
});
await this.saveEcs(join('homesteads', `${id}`), ecs);
}
async createPlayer(id) {
const player = {
Camera: {},
Controlled: {},
Direction: {direction: 2},
Ecs: {path: join('homesteads', `${id}`)},
Emitter: {},
Forces: {},
Interacts: {},
Inventory: {
slots: {
// 1: {
// qty: 10,
// source: '/assets/potion/potion.json',
// },
2: {
qty: 1,
source: '/assets/watering-can/watering-can.json',
},
3: {
qty: 1,
source: '/assets/tomato-seeds/tomato-seeds.json',
},
4: {
qty: 1,
source: '/assets/hoe/hoe.json',
},
},
},
Health: {health: 100},
Position: {x: 368, y: 368},
Speed: {speed: 100},
Sound: {},
Sprite: {
anchor: {x: 0.5, y: 0.8},
animation: 'moving:down',
frame: 0,
frames: 8,
source: '/assets/dude/dude.json',
speed: 0.115,
},
Ticking: {},
VisibleAabb: {},
Wielder: {
activeSlot: 0,
},
};
const buffer = (new TextEncoder()).encode(JSON.stringify(player));
await this.server.writeData(
join('players', `${id}`),
buffer,
);
return buffer;
}
async disconnectPlayer(connection) { async disconnectPlayer(connection) {
const {entity, id} = this.connectedPlayers.get(connection); const connectedPlayer = this.connectedPlayers.get(connection);
if (!connectedPlayer) {
return;
}
const {entity, id} = connectedPlayer;
const ecs = this.ecses[entity.Ecs.path]; const ecs = this.ecses[entity.Ecs.path];
await this.savePlayer(id, entity); await this.savePlayer(id, entity);
ecs.destroy(entity.id); ecs.destroy(entity.id);
this.connectedPlayers.delete(connection); this.connectedPlayers.delete(connection);
this.connections.splice(this.connections.indexOf(connection), 1); this.incomingActions.delete(connection);
} }
async load() { async load() {
@ -250,7 +183,7 @@ export default class Engine {
async loadEcs(path) { async loadEcs(path) {
this.ecses[path] = await this.Ecs.deserialize( this.ecses[path] = await this.Ecs.deserialize(
this.createEcs(), createEcs(this.Ecs),
await this.server.readData(path), await this.server.readData(path),
); );
} }
@ -264,8 +197,23 @@ export default class Engine {
if ('ENOENT' !== error.code) { if ('ENOENT' !== error.code) {
throw error; throw error;
} }
await this.createHomestead(id); const homestead = await createHomestead(this.Ecs);
buffer = await this.createPlayer(id); homestead.get(2).Ecs.path = ['houses', `${id}`].join('/');
await this.saveEcs(
['homesteads', `${id}`].join('/'),
homestead,
);
const house = await createHouse(this.Ecs);
house.get(2).Ecs.path = ['homesteads', `${id}`].join('/');
await this.saveEcs(
['houses', `${id}`].join('/'),
house,
);
buffer = await createPlayer(id);
await this.server.writeData(
['players', `${id}`].join('/'),
buffer,
);
} }
return JSON.parse((new TextDecoder()).decode(buffer)); return JSON.parse((new TextDecoder()).decode(buffer));
} }
@ -321,7 +269,10 @@ export default class Engine {
} }
update(elapsed) { update(elapsed) {
for (const connection of this.connections) { for (const [connection, {entity}] of this.connectedPlayers) {
if (!entity) {
continue;
}
this.server.send( this.server.send(
connection, connection,
{ {

View File

@ -2,6 +2,9 @@ import {del, get, set} from 'idb-keyval';
import {encode} from '@/packets/index.js'; import {encode} from '@/packets/index.js';
import '../../create-homestead.js';
import '../../create-player.js';
import Engine from '../../engine.js'; import Engine from '../../engine.js';
import Server from './server.js'; import Server from './server.js';
@ -57,15 +60,52 @@ onmessage = async (event) => {
})(); })();
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.accept('../../engine.js', async ({default: Engine}) => { const createResolver = () => {
let r;
const promise = new Promise((resolve) => {
r = resolve;
});
promise.resolve = r;
return promise;
};
const beforeResolver = createResolver();
const resolvers = [];
import.meta.hot.on('vite:beforeUpdate', async () => {
engine.stop();
await engine.disconnectPlayer(0); await engine.disconnectPlayer(0);
if (Engine.prototype.createHomestead.toString() !== engine.createHomestead.toString()) { beforeResolver.resolve();
delete engine.ecses['homesteads/0']; });
await engine.server.removeData('homesteads/0'); import.meta.hot.accept('../../engine.js');
const newEngine = new Engine(WorkerServer); import.meta.hot.accept('../../create-player.js', async ({default: createPlayer}) => {
await newEngine.createHomestead(0); const resolver = createResolver();
} resolvers.push(resolver);
await beforeResolver;
const oldBuffer = await engine.server.readData('players/0');
const oldPlayer = JSON.parse((new TextDecoder()).decode(oldBuffer));
const buffer = await createPlayer(0);
const player = JSON.parse((new TextDecoder()).decode(buffer));
// Less jarring
player.Ecs = oldPlayer.Ecs;
player.Direction = oldPlayer.Direction;
player.Position = oldPlayer.Position;
await engine.server.writeData('players/0', (new TextEncoder()).encode(JSON.stringify(player)));
resolver.resolve();
});
import.meta.hot.accept('../../create-homestead.js', async ({default: createHomestead}) => {
const resolver = createResolver();
resolvers.push(resolver);
await beforeResolver;
delete engine.ecses['homesteads/0'];
await engine.server.removeData('homesteads/0');
const homestead = await createHomestead(engine.Ecs);
homestead.get(2).Ecs.path = 'houses/0';
await engine.saveEcs('homesteads/0', homestead);
resolver.resolve();
});
import.meta.hot.on('vite:afterUpdate', async () => {
await Promise.all(resolvers);
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'})); postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
close(); close();
}); });
import.meta.hot.accept();
} }

View File

@ -0,0 +1,3 @@
import Packet from '@/net/packet.js';
export default class EcsChange extends Packet {}

View File

@ -2,6 +2,7 @@ import {Container} from '@pixi/react';
import {useState} from 'react'; import {useState} from 'react';
import {RESOLUTION} from '@/constants.js'; import {RESOLUTION} from '@/constants.js';
import {usePacket} from '@/context/client.js';
import {useEcs, useEcsTick} from '@/context/ecs.js'; import {useEcs, useEcsTick} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js'; import {useMainEntity} from '@/context/main-entity.js';
@ -15,7 +16,13 @@ export default function Ecs({scale}) {
const [ecs] = useEcs(); const [ecs] = useEcs();
const [entities, setEntities] = useState({}); const [entities, setEntities] = useState({});
const [mainEntity] = useMainEntity(); const [mainEntity] = useMainEntity();
usePacket('EcsChange', async () => {
setEntities({});
}, [setEntities]);
useEcsTick((payload) => { useEcsTick((payload) => {
if (!ecs) {
return;
}
const updatedEntities = {...entities}; const updatedEntities = {...entities};
for (const id in payload) { for (const id in payload) {
const update = payload[id]; const update = payload[id];
@ -34,14 +41,17 @@ export default function Ecs({scale}) {
} }
setEntities(updatedEntities); setEntities(updatedEntities);
}, [ecs, entities, mainEntity]); }, [ecs, entities, mainEntity]);
if (!mainEntity) { if (!ecs || !mainEntity) {
return false; return false;
} }
const entity = ecs.get(mainEntity); const entity = ecs.get(mainEntity);
if (!entity) {
return false;
}
const {Direction, Position, Wielder} = entity; const {Direction, Position, Wielder} = entity;
const projected = Wielder.activeItem()?.project(Position.tile, Direction.direction) const projected = Wielder.activeItem()?.project(Position.tile, Direction.direction)
const {Camera} = entity; const {Camera} = entity;
const {TileLayers: {layers: [layer]}, Water: {water}} = ecs.get(1); const {TileLayers: {layers: [layer]}, Water: WaterEcs} = ecs.get(1);
const [cx, cy] = [ const [cx, cy] = [
Math.round((Camera.x * scale) - RESOLUTION.x / 2), Math.round((Camera.x * scale) - RESOLUTION.x / 2),
Math.round((Camera.y * scale) - RESOLUTION.y / 2), Math.round((Camera.y * scale) - RESOLUTION.y / 2),
@ -53,7 +63,9 @@ export default function Ecs({scale}) {
y={-cy} y={-cy}
> >
<TileLayer tileLayer={layer} /> <TileLayer tileLayer={layer} />
<Water tileLayer={layer} water={water} /> {WaterEcs && (
<Water tileLayer={layer} water={WaterEcs.water} />
)}
{projected && ( {projected && (
<TargetingGrid <TargetingGrid
tileLayer={layer} tileLayer={layer}

View File

@ -1,6 +1,7 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import addKeyListener from '@/add-key-listener.js'; import addKeyListener from '@/add-key-listener.js';
import ClientEcs from '@/client-ecs';
import {RESOLUTION} from '@/constants.js'; import {RESOLUTION} from '@/constants.js';
import {useClient, usePacket} from '@/context/client.js'; import {useClient, usePacket} from '@/context/client.js';
import {useDebug} from '@/context/debug.js'; import {useDebug} from '@/context/debug.js';
@ -24,12 +25,26 @@ export default function Ui({disconnected}) {
const client = useClient(); const client = useClient();
const [mainEntity, setMainEntity] = useMainEntity(); const [mainEntity, setMainEntity] = useMainEntity();
const [debug, setDebug] = useDebug(); const [debug, setDebug] = useDebug();
const [ecs] = useEcs(); const [ecs, setEcs] = useEcs();
const [showDisconnected, setShowDisconnected] = useState(false); const [showDisconnected, setShowDisconnected] = useState(false);
const [bufferSlot, setBufferSlot] = useState(); const [bufferSlot, setBufferSlot] = useState();
const [hotbarSlots, setHotbarSlots] = useState(emptySlots()); const [hotbarSlots, setHotbarSlots] = useState(emptySlots());
const [activeSlot, setActiveSlot] = useState(0); const [activeSlot, setActiveSlot] = useState(0);
const [scale, setScale] = useState(2); const [scale, setScale] = useState(2);
const [Components, setComponents] = useState();
const [Systems, setSystems] = useState();
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(() => {
setEcs(new ClientEcs({Components, Systems}));
}, [Components, setEcs, Systems]);
useEffect(() => { useEffect(() => {
let handle; let handle;
if (disconnected) { if (disconnected) {
@ -165,6 +180,10 @@ export default function Ui({disconnected}) {
} }
}); });
}, [client, debug, setDebug, setScale]); }, [client, debug, setDebug, setScale]);
usePacket('EcsChange', async () => {
setMainEntity(undefined);
setEcs(new ClientEcs({Components, Systems}));
}, [Components, Systems, setEcs, setMainEntity]);
usePacket('Tick', async (payload, client) => { usePacket('Tick', async (payload, client) => {
if (0 === Object.keys(payload.ecs).length) { if (0 === Object.keys(payload.ecs).length) {
return; return;
@ -173,7 +192,7 @@ export default function Ui({disconnected}) {
for (const listener of client.listeners[':Ecs'] ?? []) { for (const listener of client.listeners[':Ecs'] ?? []) {
listener(payload.ecs); listener(payload.ecs);
} }
}, [hotbarSlots, mainEntity, setMainEntity]); }, [ecs]);
useEcsTick((payload) => { useEcsTick((payload) => {
let localMainEntity = mainEntity; let localMainEntity = mainEntity;
for (const id in payload) { for (const id in payload) {
@ -201,21 +220,67 @@ export default function Ui({disconnected}) {
} }
} }
} }
}, [hotbarSlots, mainEntity, setMainEntity]); }, [ecs, mainEntity]);
useEffect(() => {
function onContextMenu(event) {
event.preventDefault();
}
document.body.addEventListener('contextmenu', onContextMenu);
return () => {
document.body.removeEventListener('contextmenu', onContextMenu);
};
}, [])
return ( return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div <div
className={styles.ui} className={styles.ui}
onMouseDown={(event) => { onMouseDown={(event) => {
client.send({ switch (event.button) {
type: 'Action', case 0:
payload: {type: 'use', value: 'keyDown'}, client.send({
}); type: 'Action',
payload: {type: 'use', value: 1},
});
break;
case 2:
client.send({
type: 'Action',
payload: {type: 'interact', value: 1},
});
break;
}
event.preventDefault();
}} }}
onMouseUp={(event) => { onMouseUp={(event) => {
client.send({ switch (event.button) {
type: 'Action', case 0:
payload: {type: 'use', value: 'keyUp'}, client.send({
}); type: 'Action',
payload: {type: 'use', value: 0},
});
break;
case 2:
client.send({
type: 'Action',
payload: {type: 'interact', value: 0},
});
break;
}
event.preventDefault();
}}
onWheel={(event) => {
if (event.deltaY > 0) {
client.send({
type: 'Action',
payload: {type: 'changeSlot', value: 1 + ((activeSlot + 1) % 10)},
});
}
else {
client.send({
type: 'Action',
payload: {type: 'changeSlot', value: 1 + ((activeSlot + 9) % 10)},
});
}
}} }}
> >
<style> <style>

View File

@ -7,47 +7,8 @@ import ClientContext from '@/context/client.js';
import DebugContext from '@/context/debug.js'; import DebugContext from '@/context/debug.js';
import EcsContext from '@/context/ecs.js'; import EcsContext from '@/context/ecs.js';
import MainEntityContext from '@/context/main-entity.js'; import MainEntityContext from '@/context/main-entity.js';
import Ecs from '@/ecs/ecs.js';
import Components from '@/ecs-components/index.js';
import Systems from '@/ecs-systems/index.js';
import Ui from '@/react-components/ui.jsx'; import Ui from '@/react-components/ui.jsx';
import {juggleSession} from '@/session.server'; import {juggleSession} from '@/session.server';
import Script from '@/util/script.js';
import {LRUCache} from 'lru-cache';
export const cache = new LRUCache({
max: 128,
});
class ClientEcs extends Ecs {
async readAsset(uri) {
if (!cache.has(uri)) {
let promise, resolve, reject;
promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
cache.set(uri, promise);
fetch(new URL(uri, window.location.origin))
.then(async (response) => {
resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0));
})
.catch(reject);
}
return cache.get(uri);
}
async readJson(uri) {
const chars = await this.readAsset(uri);
return chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {};
}
async readScript(uri, context = {}) {
const code = await this.readAsset(uri);
if (code.byteLength > 0) {
return Script.fromCode((new TextDecoder()).decode(code), context);
}
}
}
export async function loader({request}) { export async function loader({request}) {
await juggleSession(request); await juggleSession(request);
@ -59,8 +20,9 @@ export default function PlaySpecific() {
const assetsTuple = useState({}); const assetsTuple = useState({});
const [client, setClient] = useState(); const [client, setClient] = useState();
const mainEntityTuple = useState(); const mainEntityTuple = useState();
const setMainEntity = mainEntityTuple[1];
const debugTuple = useState(false); const debugTuple = useState(false);
const ecsTuple = useState(new ClientEcs({Components, Systems})); const ecsTuple = useState();
const [disconnected, setDisconnected] = useState(false); const [disconnected, setDisconnected] = useState(false);
const params = useParams(); const params = useParams();
const [type, url] = params['*'].split('/'); const [type, url] = params['*'].split('/');
@ -117,9 +79,10 @@ export default function PlaySpecific() {
}; };
}, [client]); }, [client]);
useEffect(() => { useEffect(() => {
if (!disconnected) { if (!client || !disconnected) {
return; return;
} }
setMainEntity(undefined);
async function reconnect() { async function reconnect() {
await client.connect(url); await client.connect(url);
} }
@ -128,7 +91,7 @@ export default function PlaySpecific() {
return () => { return () => {
clearInterval(handle); clearInterval(handle);
}; };
}, [client, disconnected, url]); }, [client, disconnected, setMainEntity, url]);
useEffect(() => { useEffect(() => {
let source = true; let source = true;
async function play() { async function play() {

View File

@ -0,0 +1,10 @@
ecs.switchEcs(
other,
entity.Ecs.path,
{
Position: {
x: 74,
y: 108,
},
},
);

View File

@ -0,0 +1,10 @@
ecs.switchEcs(
other,
entity.Ecs.path,
{
Position: {
x: 72,
y: 304,
},
},
);