Compare commits

...

17 Commits

Author SHA1 Message Date
cha0s
5c619b26c0 fun: life and death 2024-07-27 12:31:52 -05:00
cha0s
fb747b38e6 feat: damage types 2024-07-27 10:52:49 -05:00
cha0s
2b91a49997 perf: damage rendering in CSS 2024-07-27 09:54:03 -05:00
cha0s
ebf62613ef feat: collision filtering 2024-07-26 19:28:28 -05:00
cha0s
08dfe8ac29 fun: das a lodda damage 2024-07-26 18:05:24 -05:00
cha0s
091e19c7de refactor: safety first 2024-07-26 11:36:32 -05:00
cha0s
5ff4eb2991 fun: smokin some kittens 2024-07-26 11:25:01 -05:00
cha0s
c19beead68 feat: destruction dependency 2024-07-26 10:31:58 -05:00
cha0s
628e0cae48 fix: collision mutex 2024-07-26 08:40:53 -05:00
cha0s
91d0f481d6 fix: clean up 2024-07-26 07:53:50 -05:00
cha0s
8978ee89b8 perf: nop 2024-07-26 07:43:06 -05:00
cha0s
df56438656 todo: assignment pattern 2024-07-26 07:04:36 -05:00
cha0s
dd675efa5c refactor: collisions 2024-07-26 06:00:37 -05:00
cha0s
eaf144668a refactor: collision scripts 2024-07-26 02:20:12 -05:00
cha0s
a2b30d2f8b refactor: script names and body bounds 2024-07-25 16:39:05 -05:00
cha0s
123c95ca1c fun: sword defaults 2024-07-25 15:31:44 -05:00
cha0s
adcdc81423 fun: magic swords 2024-07-25 11:00:25 -05:00
38 changed files with 862 additions and 197 deletions

View File

@ -446,6 +446,26 @@ test('executes member expressions', async () => {
}); });
}); });
test.todo('declares with assignment pattern', async () => {
let sandbox;
sandbox = new Sandbox(
await parse(`
const {Player: {id: owner = 0} = {}, Position} = {};
owner;
`),
);
expect(sandbox.run().value)
.to.equal(0);
sandbox = new Sandbox(
await parse(`
const {Player: {id: [owner = 0]} = {id: []}, Position} = {};
owner;
`),
);
expect(sandbox.run().value)
.to.equal(0);
});
test('handles nested yields', async () => { test('handles nested yields', async () => {
expect( expect(
await finish(new Sandbox( await finish(new Sandbox(

View File

@ -0,0 +1,53 @@
import Component from '@/ecs/component.js';
export default class Alive extends Component {
instanceFromSchema() {
const {ecs} = this;
return class AliveInstance extends super.instanceFromSchema() {
$$dead = false;
acceptDamage(amount) {
const health = Math.min(this.maxHealth, Math.max(0, this.health + amount));
this.health = health;
if (0 === health) {
this.die();
}
}
die() {
if (this.$$dead) {
return;
}
this.$$dead = true;
const {Ticking} = ecs.get(this.entity);
if (Ticking) {
const ticker = this.$$death.ticker();
ecs.addDestructionDependency(this.entity.id, ticker);
Ticking.add(ticker);
}
}
};
}
async load(instance) {
// heavy handed...
if ('undefined' !== typeof window) {
return;
}
instance.$$death = await this.ecs.readScript(
instance.deathScript,
{
ecs: this.ecs,
entity: this.ecs.get(instance.entity),
},
);
if (0 === instance.maxHealth) {
instance.maxHealth = instance.health;
}
}
static properties = {
deathScript: {
defaultValue: '/assets/misc/death-default.js',
type: 'string',
},
health: {type: 'uint32'},
maxHealth: {type: 'uint32'},
};
}

View File

@ -9,7 +9,9 @@ export default class Collider extends Component {
return class ColliderInstance extends super.instanceFromSchema() { return class ColliderInstance extends super.instanceFromSchema() {
$$aabb = {x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity}; $$aabb = {x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity};
$$aabbs = []; $$aabbs = [];
collidingWith = {}; $$collisionStart;
$$collisionEnd;
$$intersections = new Map();
get aabb() { get aabb() {
const {Position: {x: px, y: py}} = ecs.get(this.entity); const {Position: {x: px, y: py}} = ecs.get(this.entity);
return { return {
@ -32,6 +34,137 @@ export default class Collider extends Component {
} }
return aabbs; return aabbs;
} }
endIntersections(other, intersections) {
const otherEntity = ecs.get(other.entity);
const thisEntity = ecs.get(this.entity);
for (const intersection of intersections) {
const [body, otherBody] = [
intersection.entity.bodies[intersection.i],
intersection.other.bodies[intersection.j],
];
if (this.$$collisionEnd) {
const script = this.$$collisionEnd.clone();
script.context.other = otherEntity;
script.context.pair = [body, otherBody];
const ticker = script.ticker();
ecs.addDestructionDependency(thisEntity.id, ticker);
ecs.addDestructionDependency(otherEntity.id, ticker);
thisEntity.Ticking.add(ticker);
}
if (other.$$collisionEnd) {
const script = other.$$collisionEnd.clone();
script.context.other = thisEntity;
script.context.pair = [otherBody, body];
const ticker = script.ticker();
ecs.addDestructionDependency(thisEntity.id, ticker);
ecs.addDestructionDependency(otherEntity.id, ticker);
otherEntity.Ticking.add(ticker);
}
}
this.$$intersections.delete(other);
other.$$intersections.delete(this);
}
checkCollision(other) {
const otherEntity = ecs.get(other.entity);
const thisEntity = ecs.get(this.entity);
const intersections = this.intersectionsWith(other);
const activeIntersections = this.$$intersections.get(other) || new Set();
if (0 === intersections.length) {
// had none; have none
if (0 === activeIntersections.size) {
return;
}
this.endIntersections(other, intersections);
return;
}
for (const intersection of intersections) {
// new pair - start
const [body, otherBody] = [
intersection.entity.bodies[intersection.i],
intersection.other.bodies[intersection.j],
];
let hasMatchingIntersection = false;
for (const activeIntersection of activeIntersections) {
if (
(
activeIntersection.entity === intersection.entity
&& activeIntersection.other === intersection.other
&& activeIntersection.i === intersection.i
&& activeIntersection.j === intersection.j
)
|| (
activeIntersection.entity === intersection.other
&& activeIntersection.other === intersection.entity
&& activeIntersection.i === intersection.j
&& activeIntersection.j === intersection.i
)
) {
hasMatchingIntersection = true;
break;
}
}
if (!hasMatchingIntersection) {
if (this.$$collisionStart) {
const script = this.$$collisionStart.clone();
script.context.other = otherEntity;
script.context.pair = [body, otherBody];
const ticker = script.ticker();
ecs.addDestructionDependency(otherEntity.id, ticker);
ecs.addDestructionDependency(thisEntity.id, ticker);
thisEntity.Ticking.add(ticker);
}
if (other.$$collisionStart) {
const script = other.$$collisionStart.clone();
script.context.other = thisEntity;
script.context.pair = [otherBody, body];
const ticker = script.ticker();
ecs.addDestructionDependency(otherEntity.id, ticker);
ecs.addDestructionDependency(thisEntity.id, ticker);
otherEntity.Ticking.add(ticker);
}
activeIntersections.add(intersection);
}
// undo restricted movement
if (!body.unstoppable && otherBody.impassable) {
const j = this.bodies.indexOf(body);
const oj = other.bodies.indexOf(otherBody);
const aabb = this.$$aabbs[j];
const otherAabb = other.aabbs[oj];
const {Position} = thisEntity;
if (!intersects(
{
x0: aabb.x0 + Position.lastX,
x1: aabb.x1 + Position.lastX,
y0: aabb.y0 + Position.y,
y1: aabb.y1 + Position.y,
},
otherAabb,
)) {
Position.x = Position.lastX
}
else if (!intersects(
{
x0: aabb.x0 + Position.x,
x1: aabb.x1 + Position.x,
y0: aabb.y0 + Position.lastY,
y1: aabb.y1 + Position.lastY,
},
otherAabb,
)) {
Position.y = Position.lastY
}
else {
Position.x = Position.lastX
Position.y = Position.lastY
}
break;
}
}
if (activeIntersections.size > 0) {
this.$$intersections.set(other, activeIntersections);
other.$$intersections.set(this, activeIntersections);
}
}
closest(aabb) { closest(aabb) {
const entity = ecs.get(this.entity); const entity = ecs.get(this.entity);
return Array.from(ecs.system('Colliders').within(aabb)) return Array.from(ecs.system('Colliders').within(aabb))
@ -41,14 +174,12 @@ export default class Collider extends Component {
}); });
} }
destroy() { destroy() {
const entity = ecs.get(this.entity); for (const [other] of this.$$intersections) {
for (const otherId in this.collidingWith) { other.$$intersections.delete(this);
const other = ecs.get(otherId);
delete entity.Collider.collidingWith[other.id];
delete other.Collider.collidingWith[entity.id];
} }
this.$$intersections.clear();
} }
isCollidingWith(other) { intersectionsWith(other) {
const {aabb, aabbs} = this; const {aabb, aabbs} = this;
const {aabb: otherAabb, aabbs: otherAabbs} = other; const {aabb: otherAabb, aabbs: otherAabbs} = other;
const intersections = []; const intersections = [];
@ -57,10 +188,26 @@ export default class Collider extends Component {
} }
for (const i in aabbs) { for (const i in aabbs) {
const aabb = aabbs[i]; const aabb = aabbs[i];
const body = this.bodies[i];
for (const j in otherAabbs) { for (const j in otherAabbs) {
const otherAabb = otherAabbs[j]; const otherAabb = otherAabbs[j];
const otherBody = other.bodies[j];
if (body.group === otherBody.group && body.group < 0) {
continue;
}
if (body.group !== otherBody.group || 0 === body.group) {
if (0 === (otherBody.mask & body.bits) || 0 === (body.mask & otherBody.bits)) {
continue;
}
}
// todo accuracy
if (intersects(aabb, otherAabb)) { if (intersects(aabb, otherAabb)) {
intersections.push([this.bodies[i], other.bodies[j]]); intersections.push({
entity: this,
other,
i,
j,
});
} }
} }
} }
@ -107,19 +254,27 @@ export default class Collider extends Component {
} }
} }
async load(instance) { async load(instance) {
for (const i in instance.bodies) {
instance.bodies[i] = {
...this.constructor.schema.constructor.defaultValue(
this.constructor.schema.specification.concrete.properties.bodies.concrete.subtype,
),
...instance.bodies[i],
};
}
instance.updateAabbs(); instance.updateAabbs();
// heavy handed... // heavy handed...
if ('undefined' !== typeof window) { if ('undefined' !== typeof window) {
return; return;
} }
instance.collisionEndScriptInstance = await this.ecs.readScript( instance.$$collisionEnd = await this.ecs.readScript(
instance.collisionEndScript, instance.collisionEndScript,
{ {
ecs: this.ecs, ecs: this.ecs,
entity: this.ecs.get(instance.entity), entity: this.ecs.get(instance.entity),
}, },
); );
instance.collisionStartScriptInstance = await this.ecs.readScript( instance.$$collisionStart = await this.ecs.readScript(
instance.collisionStartScript, instance.collisionStartScript,
{ {
ecs: this.ecs, ecs: this.ecs,
@ -133,15 +288,15 @@ export default class Collider extends Component {
subtype: { subtype: {
type: 'object', type: 'object',
properties: { properties: {
bits: {defaultValue: 0x00000001, type: 'uint32'},
impassable: {type: 'uint8'}, impassable: {type: 'uint8'},
group: {type: 'int32'},
mask: {defaultValue: 0xFFFFFFFF, type: 'uint32'},
points: { points: {
type: 'array', type: 'array',
subtype: vector2d('int16'), subtype: vector2d('int16'),
}, },
tags: { unstoppable: {type: 'uint8'},
type: 'array',
subtype: {type: 'string'},
},
}, },
}, },
}, },

View File

@ -4,8 +4,9 @@ export default class Interactive extends Component {
instanceFromSchema() { instanceFromSchema() {
const {ecs} = this; const {ecs} = this;
return class ControlledInstance extends super.instanceFromSchema() { return class ControlledInstance extends super.instanceFromSchema() {
$$interact;
interact(initiator) { interact(initiator) {
const script = this.interactScriptInstance.clone(); const script = this.$$interact.clone();
script.context.initiator = initiator; script.context.initiator = initiator;
const {Ticking} = ecs.get(this.entity); const {Ticking} = ecs.get(this.entity);
Ticking.add(script.ticker()); Ticking.add(script.ticker());
@ -23,7 +24,7 @@ export default class Interactive extends Component {
if ('undefined' !== typeof window) { if ('undefined' !== typeof window) {
return; return;
} }
instance.interactScriptInstance = await this.ecs.readScript( instance.$$interact = await this.ecs.readScript(
instance.interactScript, instance.interactScript,
{ {
ecs: this.ecs, ecs: this.ecs,

View File

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

View File

@ -1,17 +1,18 @@
import Component from '@/ecs/component.js'; import Component from '@/ecs/component.js';
import Script from '@/util/script.js';
export default class Plant extends Component { export default class Plant extends Component {
instanceFromSchema() { instanceFromSchema() {
const {ecs} = this; const {ecs} = this;
const Instance = super.instanceFromSchema(); const Instance = super.instanceFromSchema();
return class PlantInstance extends Instance { return class PlantInstance extends Instance {
mayGrow() { $$grow;
return this.mayGrowScriptInstance.evaluate(); $$mayGrow;
}
grow() { grow() {
const {Ticking} = ecs.get(this.entity); const {Ticking} = ecs.get(this.entity);
Ticking.add(this.growScriptInstance.ticker()); Ticking.add(this.$$grow.ticker());
}
mayGrow() {
return this.$$mayGrow.evaluate();
} }
}; };
} }
@ -20,14 +21,14 @@ export default class Plant extends Component {
if ('undefined' !== typeof window) { if ('undefined' !== typeof window) {
return; return;
} }
instance.growScriptInstance = await this.ecs.readScript( instance.$$grow = await this.ecs.readScript(
instance.growScript, instance.growScript,
{ {
ecs: this.ecs, ecs: this.ecs,
plant: instance, plant: instance,
}, },
); );
instance.mayGrowScriptInstance = await this.ecs.readScript( instance.$$mayGrow = await this.ecs.readScript(
instance.mayGrowScript, instance.mayGrowScript,
{ {
ecs: this.ecs, ecs: this.ecs,

View File

@ -14,7 +14,7 @@ export default class Ticking extends Component {
}); });
} }
reset() { destroy() {
this.$$finished = []; this.$$finished = [];
this.$$tickers = []; this.$$tickers = [];
} }

View File

@ -0,0 +1,38 @@
import Component from '@/ecs/component.js';
export const DamageTypes = {
PAIN: 0,
HEALING: 1,
MANA: 2,
};
export default class Vulnerable extends Component {
mergeDiff(original, update) {
const merged = {};
if (update.damage) {
merged.damage = {
...original.damage,
...update.damage,
}
}
return merged;
}
instanceFromSchema() {
const Component = this;
return class VulnerableInstance extends super.instanceFromSchema() {
id = 0;
Types = DamageTypes;
damage(specification) {
const {Alive} = Component.ecs.get(this.entity);
if (Alive) {
switch (specification.type) {
case DamageTypes.PAIN: {
Alive.acceptDamage(specification.amount);
}
}
}
Component.markChange(this.entity, 'damage', {[this.id++]: specification});
}
};
}
}

View File

@ -23,7 +23,7 @@ export default class Ecs {
deferredChanges = {} deferredChanges = {}
destroying = new Set(); $$destructionDependencies = new Map();
diff = {}; diff = {};
@ -42,6 +42,20 @@ export default class Ecs {
} }
} }
addDestructionDependency(id, promise) {
if (!this.$$destructionDependencies.has(id)) {
this.$$destructionDependencies.set(id, {promises: new Set()})
}
const {promises} = this.$$destructionDependencies.get(id);
promises.add(promise);
promise.then(() => {
promises.delete(promise);
if (!this.$$destructionDependencies.get(id)?.resolvers) {
this.$$destructionDependencies.delete(id);
}
});
}
async apply(patch) { async apply(patch) {
const creating = []; const creating = [];
const destroying = []; const destroying = [];
@ -213,11 +227,14 @@ export default class Ecs {
} }
destroy(entityId) { destroy(entityId) {
this.destroying.add(entityId); if (!this.$$destructionDependencies.has(entityId)) {
this.$$destructionDependencies.set(entityId, {promises: new Set()});
} }
const dependencies = this.$$destructionDependencies.get(entityId);
destroyImmediately(entityId) { if (!dependencies.resolvers) {
this.destroyMany([entityId]); dependencies.resolvers = withResolvers();
}
return dependencies.resolvers.promise;
} }
destroyAll() { destroyAll() {
@ -444,9 +461,18 @@ export default class Ecs {
System.elapsed -= System.frequency; System.elapsed -= System.frequency;
} }
} }
if (this.destroying.size > 0) { const destroying = new Set();
this.destroyMany(this.destroying); for (const [entityId, {promises, resolvers}] of this.$$destructionDependencies) {
this.destroying.clear(); if (0 === promises.size && resolvers) {
destroying.add(entityId);
}
}
if (destroying.size > 0) {
this.destroyMany(destroying);
for (const entityId of destroying) {
this.$$destructionDependencies.get(entityId).resolvers.resolve();
this.$$destructionDependencies.delete(entityId);
}
} }
} }

View File

@ -114,7 +114,7 @@ test('destroys entities', async () => {
expect(ecs.get(entity)) expect(ecs.get(entity))
.to.be.undefined; .to.be.undefined;
expect(() => { expect(() => {
ecs.destroyImmediately(entity); ecs.destroyMany([entity]);
}) })
.to.throw(); .to.throw();
}); });
@ -275,7 +275,8 @@ test('generates diffs for deletions', async () => {
let entity; let entity;
entity = await ecs.create(); entity = await ecs.create();
ecs.setClean(); ecs.setClean();
ecs.destroyImmediately(entity); ecs.destroy(entity);
ecs.tick(0);
expect(ecs.diff) expect(ecs.diff)
.to.deep.equal({[entity]: false}); .to.deep.equal({[entity]: false});
}); });

View File

@ -2,11 +2,12 @@ import {expect, test} from 'vitest';
import Schema from './schema.js'; import Schema from './schema.js';
test('defaults values', () => {
const compare = (specification, value) => { const compare = (specification, value) => {
expect(new Schema(specification).defaultValue()) expect(new Schema(specification).defaultValue())
.to.deep.equal(value); .to.deep.equal(value);
}; };
test('defaults values', () => {
[ [
'uint8', 'uint8',
'int8', 'int8',
@ -54,6 +55,22 @@ test('defaults values', () => {
); );
}); });
test.todo('defaults nested values', () => {
compare(
{
type: 'array',
subtype: {
type: 'object',
properties: {
foo: {defaultValue: 'bar', type: 'string'},
},
},
},
[{}],
[{foo: 'bar'}],
);
});
test('validates a schema', () => { test('validates a schema', () => {
[ [
'uint8', 'uint8',

View File

@ -1,5 +1,4 @@
import {System} from '@/ecs/index.js'; import {System} from '@/ecs/index.js';
import {intersects} from '@/util/math.js';
import SpatialHash from '@/util/spatial-hash.js'; import SpatialHash from '@/util/spatial-hash.js';
export default class Colliders extends System { export default class Colliders extends System {
@ -39,7 +38,7 @@ export default class Colliders extends System {
} }
tick() { tick() {
const collisions = new Map(); const checked = new Map();
for (const entity of this.ecs.changed(['Direction'])) { for (const entity of this.ecs.changed(['Direction'])) {
if (!entity.Collider) { if (!entity.Collider) {
continue; continue;
@ -47,99 +46,32 @@ export default class Colliders extends System {
entity.Collider.updateAabbs(); entity.Collider.updateAabbs();
} }
for (const entity of this.ecs.changed(['Position'])) { for (const entity of this.ecs.changed(['Position'])) {
if (!entity.Collider) {
continue;
}
this.updateHash(entity); this.updateHash(entity);
} }
for (const entity of this.ecs.changed(['Position'])) { for (const entity of this.ecs.changed(['Position'])) {
if (!entity.Collider) { if (!entity.Collider) {
continue; continue;
} }
collisions.set(entity, new Set()); if (!checked.has(entity)) {
for (const other of this.within(entity.Collider.aabb)) { checked.set(entity, new Set());
if (entity === other) { }
const within = this.within(entity.Collider.aabb);
for (const other of within) {
if (entity === other || !other.Collider) {
continue; continue;
} }
if (!collisions.has(other)) { if (!checked.has(other)) {
collisions.set(other, new Set()); checked.set(other, new Set());
} }
if (!other.Collider || collisions.get(other).has(entity)) { if (checked.get(entity).has(other)) {
continue; continue;
} }
const intersections = entity.Collider.isCollidingWith(other.Collider); checked.get(other).add(entity);
if (intersections.length > 0) { entity.Collider.checkCollision(other.Collider);
collisions.get(entity).add(other);
if (!entity.Collider.collidingWith[other.id]) {
entity.Collider.collidingWith[other.id] = true;
other.Collider.collidingWith[entity.id] = true;
if (entity.Collider.collisionStartScriptInstance) {
const script = entity.Collider.collisionStartScriptInstance.clone();
script.context.intersections = intersections;
script.context.other = other;
entity.Ticking.add(script.ticker());
}
if (other.Collider.collisionStartScriptInstance) {
const script = other.Collider.collisionStartScriptInstance.clone();
script.context.intersections = intersections
.map(([l, r]) => [r, l]);
script.context.other = entity;
other.Ticking.add(script.ticker());
}
}
for (const i in intersections) {
const [body, otherBody] = intersections[i];
const {impassable} = otherBody;
if (impassable) {
const j = entity.Collider.bodies.indexOf(body);
const oj = other.Collider.bodies.indexOf(otherBody);
const aabb = entity.Collider.$$aabbs[j];
const otherAabb = other.Collider.aabbs[oj];
if (!intersects(
{
x0: aabb.x0 + entity.Position.lastX,
x1: aabb.x1 + entity.Position.lastX,
y0: aabb.y0 + entity.Position.y,
y1: aabb.y1 + entity.Position.y,
},
otherAabb,
)) {
entity.Position.x = entity.Position.lastX
}
else if (!intersects(
{
x0: aabb.x0 + entity.Position.x,
x1: aabb.x1 + entity.Position.x,
y0: aabb.y0 + entity.Position.lastY,
y1: aabb.y1 + entity.Position.lastY,
},
otherAabb,
)) {
entity.Position.y = entity.Position.lastY
}
else {
entity.Position.x = entity.Position.lastX
entity.Position.y = entity.Position.lastY
}
break;
}
}
}
else {
if (entity.Collider.collidingWith[other.id]) {
if (entity.Collider.collisionEndScriptInstance) {
const script = entity.Collider.collisionEndScriptInstance.clone();
script.context.other = other;
entity.Ticking.add(script.ticker());
}
if (other.Collider.collisionEndScriptInstance) {
const script = other.Collider.collisionEndScriptInstance.clone();
script.context.other = entity;
other.Ticking.add(script.ticker());
}
delete entity.Collider.collidingWith[other.id];
delete other.Collider.collidingWith[entity.id];
} }
for (const [other, intersections] of entity.Collider.$$intersections) {
if (!within.has(this.ecs.get(other.entity))) {
entity.Collider.endIntersections(other, intersections);
} }
} }
} }

View File

@ -11,7 +11,6 @@ export default class ClientEcs extends Ecs {
constructor(specification) { constructor(specification) {
super(specification); super(specification);
[ [
'Colliders',
].forEach((defaultSystem) => { ].forEach((defaultSystem) => {
const System = this.system(defaultSystem); const System = this.system(defaultSystem);
if (System) { if (System) {

View File

@ -0,0 +1,52 @@
import {memo, useEffect, useState} from 'react';
import {DamageTypes} from '@/ecs/components/vulnerable.js';
import styles from './damage.module.css';
const damageTypeMap = {
[DamageTypes.PAIN]: styles.pain,
[DamageTypes.HEALING]: styles.healing,
[DamageTypes.MANA]: styles.mana,
};
function Damage({
camera,
damage,
scale,
zIndex,
}) {
const [randomness] = useState({
radians: Math.random() * (Math.PI / 16) - (Math.PI / 32),
x: 1 * (Math.random() - 0.5),
y: Math.random(),
})
useEffect(() => {
const handle = setTimeout(() => {
damage.onClose();
}, 1_500);
return () => {
clearTimeout(handle);
};
}, [damage]);
const {amount, position} = damage;
return (
<div
className={styles.damage}
style={{
'--magnitude': Math.max(1, Math.floor(Math.log10(Math.abs(amount)))),
'--positionX': `${position.x * scale - camera.x}px`,
'--positionY': `${position.y * scale - camera.y}px`,
'--randomnessX': randomness.x,
'--randomnessY': randomness.y,
'--shimmer': damageTypeMap[damage.type],
rotate: `${randomness.radians}rad`,
zIndex,
}}
>
<p>{Math.abs(amount)}</p>
</div>
);
}
export default memo(Damage);

View File

@ -0,0 +1,149 @@
@property --hue {
initial-value: 0;
inherits: false;
syntax: '<number>';
}
@property --opacity {
initial-value: 0;
inherits: false;
syntax: '<number>';
}
@property --scale {
initial-value: 0;
inherits: false;
syntax: '<number>';
}
@property --shimmer {
initial-value: '';
inherits: false;
syntax: '<custom-ident>';
}
@property --offsetX {
initial-value: 0;
inherits: false;
syntax: '<number>';
}
@property --offsetY {
initial-value: 0;
inherits: false;
syntax: '<number>';
}
@property --randomnessX {
initial-value: 0;
inherits: false;
syntax: '<number>';
}
@property --randomnessY {
initial-value: 0;
inherits: false;
syntax: '<number>';
}
.damage {
--hue: 0;
--opacity: 0.75;
--randomnessX: 0;
--randomnessY: 0;
--scale: 0.35;
--background: hsl(var(--hue) 100% 12.5%);
--foreground: hsl(var(--hue) 100% 50%);
animation:
fade 1.5s linear forwards,
grow 1.5s ease-in forwards,
move 1.5s cubic-bezier(0.5, 1, 0.89, 1),
var(--shimmer) 300ms infinite cubic-bezier(0.87, 0, 0.13, 1)
;
color: var(--foreground);
font-size: calc(10px + (var(--magnitude) * 12px));
opacity: var(--opacity);
overflow-wrap: break-word;
position: absolute;
margin: 0;
text-shadow:
0px -1px 0px var(--background),
1px 0px 0px var(--background),
0px 1px 0px var(--background),
-1px 0px 0px var(--background),
0px -2px 0px var(--background),
2px 0px 0px var(--background),
0px 2px 0px var(--background),
-2px 0px 0px var(--background)
;
scale: var(--scale);
translate:
calc(-50% + (1px * var(--offsetX)) + var(--positionX))
calc(-50% + (1px * var(--offsetY)) + var(--positionY))
;
user-select: none;
p {
margin: 0;
}
}
@keyframes move {
0% {
--offsetX: 0;
--offsetY: 0;
}
33%, 91.6% {
--offsetX: calc(80 * var(--randomnessX));
--offsetY: calc(-1 * (10 + var(--randomnessY) * 90));
}
}
@keyframes fade {
0% {
--opacity: 0.75;
}
25% {
--opacity: 1;
}
91.6% {
--opacity: 1;
}
100% {
--opacity: 0;
}
}
@keyframes grow {
0% {
--scale: 0.35;
}
33% {
--scale: 1;
}
45% {
--scale: 1;
}
40% {
--scale: 1.5;
}
45% {
--scale: 1;
}
91.6% {
--scale: 1;
}
95% {
--scale: 0;
}
}
@keyframes pain {
0% { --hue: 0 }
50% { --hue: 30 }
100% { --hue: 0 }
}
@keyframes healing {
0% { --hue: 120 }
50% { --hue: 90 }
100% { --hue: 120 }
}
@keyframes mana {
0% { --hue: 220 }
50% { --hue: 215 }
100% { --hue: 220 }
}

View File

@ -0,0 +1,23 @@
import styles from './damages.module.css';
import Damage from './damage.jsx';
export default function Damages({camera, damages, scale}) {
const elements = [];
for (const key in damages) {
elements.push(
<Damage
camera={camera}
damage={damages[key]}
key={key}
scale={scale}
zIndex={key}
/>
);
}
if (0 === elements.length) {
return false;
}
return <div className={styles.damages}>{elements}</div>;
}

View File

@ -0,0 +1,3 @@
.damages {
font-family: Joystix, 'Courier New', Courier, monospace;
}

View File

@ -4,6 +4,7 @@ import {usePacket} from '@/react/context/client.js';
import {useEcs, useEcsTick} from '@/react/context/ecs.js'; import {useEcs, useEcsTick} from '@/react/context/ecs.js';
import {parseLetters} from '@/util/dialogue.js'; import {parseLetters} from '@/util/dialogue.js';
import Damages from './damages.jsx';
import Entity from './entity.jsx'; import Entity from './entity.jsx';
export default function Entities({ export default function Entities({
@ -14,6 +15,7 @@ export default function Entities({
}) { }) {
const [ecs] = useEcs(); const [ecs] = useEcs();
const [entities, setEntities] = useState({}); const [entities, setEntities] = useState({});
const [damages, setDamages] = useState({});
usePacket('EcsChange', async () => { usePacket('EcsChange', async () => {
setEntities({}); setEntities({});
}, [setEntities]); }, [setEntities]);
@ -88,6 +90,22 @@ export default function Entities({
}; };
} }
} }
const {damage} = update.Vulnerable || {};
if (damage) {
for (const key in damage) {
const composite = [id, key].join('-');
damage[key].onClose = () => {
setDamages((damages) => {
const {[composite]: _, ...rest} = damages; // eslint-disable-line no-unused-vars
return rest;
})
};
setDamages((damages) => ({
...damages,
[composite]: damage[key],
}));
}
}
} }
setEntities((entities) => { setEntities((entities) => {
for (const id in deleting) { for (const id in deleting) {
@ -113,6 +131,11 @@ export default function Entities({
return ( return (
<> <>
{renderables} {renderables}
<Damages
camera={camera}
damages={damages}
scale={scale}
/>
</> </>
); );
} }

View File

@ -13,13 +13,13 @@ function Aabb({color, width = 0.5, x0, y0, x1, y1, ...rest}) {
g.clear(); g.clear();
g.lineStyle(width, color); g.lineStyle(width, color);
g.moveTo(x0, y0); g.moveTo(x0, y0);
g.lineTo(x1 + 1, y0); g.lineTo(x1, y0);
g.lineTo(x1 + 1, y1 + 1); g.lineTo(x1, y1);
g.lineTo(x0, y1 + 1); g.lineTo(x0, y1);
g.lineTo(x0, y0); g.lineTo(x0, y0);
}, [color, width, x0, x1, y0, y1]); }, [color, width, x0, x1, y0, y1]);
return ( return (
<Graphics draw={draw} x={0.5} y={0.5} {...rest} /> <Graphics draw={draw} x={0} y={0} {...rest} />
); );
} }

View File

@ -31,3 +31,8 @@ body {
font-family: "Cookbook"; font-family: "Cookbook";
src: url("/assets/fonts/Cookbook.woff") format("woff"); src: url("/assets/fonts/Cookbook.woff") format("woff");
} }
@font-face {
font-family: "Joystix";
src: url("/assets/fonts/Joystix.ttf") format("ttf");
}

View File

@ -28,15 +28,6 @@ export default async function createHomestead(id) {
entities.push({ entities.push({
Collider: { Collider: {
bodies: [ bodies: [
{
points: [
{x: -36, y: 8},
{x: -21, y: 8},
{x: -36, y: 17},
{x: -21, y: 17},
],
tags: ['door'],
},
{ {
impassable: 1, impassable: 1,
points: [ points: [
@ -47,7 +38,6 @@ export default async function createHomestead(id) {
], ],
}, },
], ],
collisionStartScript: '/assets/shit-shack/collision-start.js',
}, },
Ecs: { Ecs: {
path: ['houses', `${id}`].join('/'), path: ['houses', `${id}`].join('/'),
@ -61,6 +51,36 @@ export default async function createHomestead(id) {
Ticking: {}, Ticking: {},
VisibleAabb: {}, VisibleAabb: {},
}); });
entities.push({
Collider: {
bodies: [
{
points: [
{x: -8, y: -5},
{x: 8, y: -5},
{x: 8, y: 5},
{x: -8, y: 5},
],
},
],
collisionStartScript: `
if (other.Player) {
ecs.switchEcs(
other,
'houses/${id}',
{
Position: {
x: 72,
y: 304,
},
},
);
}
`,
},
Position: {x: 71, y: 113},
Ticking: {},
});
entities.push({ entities.push({
Collider: { Collider: {
bodies: [ bodies: [
@ -118,6 +138,7 @@ export default async function createHomestead(id) {
VisibleAabb: {}, VisibleAabb: {},
}); });
const kitty = { const kitty = {
Alive: {health: 100},
Behaving: { Behaving: {
routines: { routines: {
initial: '/assets/kitty/initial.js', initial: '/assets/kitty/initial.js',
@ -127,10 +148,10 @@ export default async function createHomestead(id) {
bodies: [ bodies: [
{ {
points: [ points: [
{x: -4, y: -4}, {x: -3.5, y: -3.5},
{x: 3, y: -4}, {x: 3.5, y: -3.5},
{x: 3, y: 3}, {x: 3.5, y: 3.5},
{x: -4, y: 3}, {x: -3.5, y: 3.5},
], ],
}, },
], ],
@ -171,8 +192,9 @@ export default async function createHomestead(id) {
Tags: {tags: ['kittan']}, Tags: {tags: ['kittan']},
Ticking: {}, Ticking: {},
VisibleAabb: {}, VisibleAabb: {},
Vulnerable: {},
}; };
for (let i = 0; i < 30; ++i) { for (let i = 0; i < 10; ++i) {
entities.push(kitty); entities.push(kitty);
} }
entities.push({ entities.push({

View File

@ -29,10 +29,20 @@ export default async function createHouse(Ecs, id) {
], ],
}, },
], ],
collisionStartScript: '/assets/house/collision-start.js', collisionStartScript: `
if (other.Player) {
ecs.switchEcs(
other,
'homesteads/${id}',
{
Position: {
x: 74,
y: 128,
}, },
Ecs: { },
path: ['homesteads', `${id}`].join('/'), );
}
`,
}, },
Position: { Position: {
x: 72, x: 72,

View File

@ -5,10 +5,10 @@ export default async function createPlayer(id) {
bodies: [ bodies: [
{ {
points: [ points: [
{x: -4, y: -4}, {x: -3.5, y: -3.5},
{x: 3, y: -4}, {x: 3.5, y: -3.5},
{x: 3, y: 3}, {x: 3.5, y: 3.5},
{x: -4, y: 3}, {x: -3.5, y: 3.5},
], ],
}, },
], ],
@ -26,6 +26,10 @@ export default async function createPlayer(id) {
qty: 100, qty: 100,
source: '/assets/potion/potion.json', source: '/assets/potion/potion.json',
}, },
2: {
qty: 1,
source: '/assets/magic-swords/magic-swords.json',
},
}, },
}, },
Health: {health: 100}, Health: {health: 100},
@ -46,7 +50,7 @@ export default async function createPlayer(id) {
Ticking: {}, Ticking: {},
VisibleAabb: {}, VisibleAabb: {},
Wielder: { Wielder: {
activeSlot: 0, activeSlot: 1,
}, },
}; };
return player; return player;

View File

@ -48,6 +48,9 @@ export default class Engine {
get frame() { get frame() {
return engine.frame; return engine.frame;
} }
lookupPlayerEntity(id) {
return engine.lookupPlayerEntity(id);
}
async readAsset(uri) { async readAsset(uri) {
if (!cache.has(uri)) { if (!cache.has(uri)) {
const {promise, resolve, reject} = withResolvers(); const {promise, resolve, reject} = withResolvers();
@ -86,15 +89,18 @@ export default class Engine {
Ecs: {path}, Ecs: {path},
...updates, ...updates,
}; };
// remove from old ECS const promises = [];
this.destroyImmediately(entity.id);
// load if necessary // load if necessary
if (!engine.ecses[path]) { if (!engine.ecses[path]) {
await engine.loadEcs(path); promises.push(engine.loadEcs(path));
} }
// remove from old ECS
promises.push(this.destroy(entity.id));
Promise.all(promises).then(async () => {
// recreate the entity in the new ECS and again associate it with the connection // 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)); connectedPlayer.entity = engine.ecses[path].get(await engine.ecses[path].create(dumped));
connectedPlayer.entity.Player.id = id connectedPlayer.entity.Player.id = id
});
} }
} }
} }
@ -252,12 +258,15 @@ export default class Engine {
if (!connectedPlayer) { if (!connectedPlayer) {
return; return;
} }
const {entity, id} = connectedPlayer;
const ecs = this.ecses[entity.Ecs.path];
await this.savePlayer(id, entity);
ecs.destroyImmediately(entity.id);
this.connectedPlayers.delete(connection); this.connectedPlayers.delete(connection);
this.incomingActions.delete(connection); this.incomingActions.delete(connection);
const {entity, id} = connectedPlayer;
const json = entity.toJSON();
const ecs = this.ecses[entity.Ecs.path];
return Promise.all([
ecs.destroy(entity.id),
this.savePlayer(id, json),
]);
} }
async load() { async load() {
@ -325,6 +334,14 @@ export default class Engine {
return player; return player;
} }
lookupPlayerEntity(id) {
for (const [, player] of this.connectedPlayers) {
if (player.id == id) {
return player.entity;
}
}
}
async saveEcs(path, ecs) { async saveEcs(path, ecs) {
const view = this.Ecs.serialize(ecs); const view = this.Ecs.serialize(ecs);
await this.server.writeData(path, view); await this.server.writeData(path, view);

View File

@ -67,8 +67,8 @@ if (import.meta.hot) {
const before = withResolvers(); const before = withResolvers();
const promises = [before.promise]; const promises = [before.promise];
import.meta.hot.on('vite:beforeUpdate', async () => { import.meta.hot.on('vite:beforeUpdate', async () => {
engine.stop();
await engine.disconnectPlayer(0); await engine.disconnectPlayer(0);
engine.stop();
await engine.saveEcses(); await engine.saveEcses();
before.resolve(); before.resolve();
}); });

View File

@ -27,6 +27,7 @@ export const {
log10, log10,
max, max,
min, min,
pow,
round, round,
sign, sign,
sin, sin,
@ -317,6 +318,10 @@ export function removeCollinear([...vertices]) {
return trimmed; return trimmed;
} }
export const smoothstep = (x) => x * x * (3 - 2 * x);
export const smootherstep = (x) => x * x * x * (x * (x * 6 - 15) + 10);
export function transform( export function transform(
vertices, vertices,
{ {

Binary file not shown.

View File

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

View File

@ -0,0 +1,12 @@
const playerEntity = ecs.lookupPlayerEntity(entity.Owned.owner);
if (playerEntity !== other && other.Vulnerable) {
const magnitude = Math.floor(Math.random() * 2)
other.Vulnerable.damage({
amount: -Math.floor(
Math.pow(10, magnitude)
+ Math.random() * (Math.pow(10, magnitude + 1) - Math.pow(10, magnitude)),
),
position: other.Position.toJSON(),
type: other.Vulnerable.Types.PAIN,
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":22,"h":22},"spriteSourceSize":{"x":0,"y":0,"w":22,"h":22},"sourceSize":{"w":22,"h":22}}},"meta":{"format":"RGBA8888","image":"./magic-sword-shot.png","rotation":2.356194490192345,"scale":1,"size":{"w":22,"h":22}}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

@ -0,0 +1,90 @@
const {Player, Position} = wielder;
const shots = [];
const EVERY = 0.03;
const N = 14;
const SPREAD = 1;
const creating = [];
const promises = []
for (let i = 0; i < N; ++i) {
promises.push(ecs.create({
Collider: {
bodies: [
{
group: -1,
points: [
{x: -2.5, y: -2.5},
{x: 14, y: -2.5},
{x: 14, y: 2.5},
{x: -2.5, y: 2.5},
],
unstoppable: 1,
},
],
collisionStartScript: '/assets/magic-swords/collision-start.js',
},
Controlled: {},
Direction: {direction: Math.TAU * (i / N)},
Forces: {},
Owned: {owner: Player ? Player.id : 0},
Position: {x: Position.x, y: Position.y},
Speed: {},
Sprite: {
alpha: 0,
source: '/assets/magic-swords/magic-sword-shot.json',
},
Ticking: {},
VisibleAabb: {},
}));
}
for (const id of await Promise.all(promises)) {
creating.push(ecs.get(id));
}
const accumulated = {};
const shot = creating.shift();
shot.Sprite.alpha = 1;
accumulated[shot.id] = 0;
shots.push(shot)
let spawner = 0;
while (shots.length > 0) {
spawner += elapsed;
if (creating.length > 0 && spawner >= EVERY) {
const shot = creating.shift();
shot.Sprite.alpha = 1;
accumulated[shot.id] = 0;
shots.push(shot)
spawner -= EVERY;
}
const destroying = [];
for (const shot of shots) {
accumulated[shot.id] += elapsed;
if (accumulated[shot.id] <= SPREAD) {
shot.Speed.speed = 100 * (1 - (accumulated[shot.id] / SPREAD))
}
else {
const toward = Math.atan2(
where.y - shot.Position.y,
where.x - shot.Position.x,
)
shot.Speed.speed = 400;
shot.Direction.direction = (Math.TAU + toward) % Math.TAU;
if (Math.distance(where, shot.Position) < 4) {
delete accumulated[shot.id];
shot.Sprite.alpha = 0;
ecs.destroy(shot.id);
destroying.push(shot);
}
}
shot.Controlled.directionMove(shot.Direction.direction);
}
for (let i = 0; i < destroying.length; ++i) {
shots.splice(shots.indexOf(destroying[i]), 1);
}
await wait(0);
}

View File

@ -0,0 +1,19 @@
const {Sprite, Ticking} = entity;
if (Sprite) {
const {promise} = transition(
entity.Sprite,
{
scaleX: {
duration: 0.25,
magnitude: -entity.Sprite.scaleX,
},
scaleY: {
duration: 0.25,
magnitude: entity.Sprite.scaleY * 2,
},
},
)
Ticking.add(promise);
await promise;
ecs.destroy(entity.id);
}

View File

@ -1,16 +0,0 @@
for (const [{tags}] of intersections) {
if (tags && tags.includes('door')) {
if (other.Player) {
ecs.switchEcs(
other,
entity.Ecs.path,
{
Position: {
x: 72,
y: 304,
},
},
);
}
}
}

View File

@ -17,7 +17,15 @@ for (let i = 0; i < 10; ++i) {
], ],
}, },
], ],
collisionStartScript: '/assets/tomato/collision-start.js', collisionStartScript: `
if (other.Inventory) {
other.Inventory.give({
qty: 1,
source: '/assets/tomato/tomato.json',
})
ecs.destroy(entity.id)
}
`,
}, },
Forces: {}, Forces: {},
Magnetic: {}, Magnetic: {},

View File

@ -1,7 +0,0 @@
if (other.Inventory) {
other.Inventory.give({
qty: 1,
source: '/assets/tomato/tomato.json',
})
ecs.destroy(entity.id)
}