feat: combat

This commit is contained in:
cha0s 2021-01-21 20:12:58 -06:00
parent 22e5b3adee
commit bd0367c8ae
18 changed files with 9392 additions and 0 deletions

View File

@ -28,6 +28,7 @@
"@avocado/sound": "^1.0.0", "@avocado/sound": "^1.0.0",
"@avocado/timing": "^2.0.0", "@avocado/timing": "^2.0.0",
"@avocado/topdown": "^2.0.0", "@avocado/topdown": "^2.0.0",
"@humus/combat": "^1.0.0",
"@humus/core": "^1.0.0", "@humus/core": "^1.0.0",
"@humus/farm": "^1.0.0", "@humus/farm": "^1.0.0",
"@humus/inventory": "^1.0.0", "@humus/inventory": "^1.0.0",

View File

@ -1088,6 +1088,19 @@
object-assign "^4.1.1" object-assign "^4.1.1"
scheduler "^0.20.1" scheduler "^0.20.1"
"@humus/combat@^1.0.0":
version "1.0.0"
resolved "http://npm.cha0sdev/@humus%2fcombat/-/combat-1.0.0.tgz#e47dd94033f179c2d8f31f5fa8fa6661c3b28ca0"
integrity sha512-js0CNAyljtOdJbO6nZkDM626nSh+NP5ASa0WKqcEAYPRrpD/o/5Rh10acGG1N/4tGPBMdMST/dzKwUW90PW9KQ==
dependencies:
"@avocado/behavior" "^2.0.0"
"@avocado/math" "^2.0.0"
"@avocado/traits" "^2.0.0"
"@latus/core" "^2.0.0"
"@latus/socket" "^2.0.0"
debug "4.3.1"
lodash.flatten "^4.4.0"
"@humus/core@^1.0.0": "@humus/core@^1.0.0":
version "1.0.0" version "1.0.0"
resolved "http://npm.cha0sdev/@humus%2fcore/-/core-1.0.0.tgz#e7e2388c5510e5d54524d3805fb9858ca0aa27fa" resolved "http://npm.cha0sdev/@humus%2fcore/-/core-1.0.0.tgz#e7e2388c5510e5d54524d3805fb9858ca0aa27fa"

View File

@ -0,0 +1 @@
module.exports = require('../../config/.eslintrc');

6
packages/combat/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
**/*.js
**/*.map
!/.*
!/webpack.config.js
!src/**/*.js
!/test/**/*.js

View File

@ -0,0 +1 @@
module.exports = require('../../config/.neutrinorc');

View File

@ -0,0 +1,47 @@
{
"name": "@humus/combat",
"version": "1.0.0",
"main": "index.js",
"author": "cha0s",
"license": "MIT",
"scripts": {
"build": "NODE_PATH=./node_modules webpack --mode production",
"clean": "rm -rf yarn.lock node_modules && yarn",
"dev": "NODE_PATH=./node_modules webpack --mode development",
"forcepub": "npm unpublish --force $(node -e 'const {name, version} = require(`./package.json`); process.stdout.write(`${name}@${version}`)') && npm publish",
"lint": "NODE_PATH=./node_modules eslint --format codeframe --ext mjs,js .",
"test": "mocha --colors test.js",
"watch": "NODE_PATH=./node_modules webpack --watch --mode development"
},
"files": [
"index.js",
"index.js.map",
"test.js",
"test.js.map"
],
"dependencies": {
"@avocado/behavior": "^2.0.0",
"@avocado/math": "^2.0.0",
"@avocado/traits": "^2.0.0",
"@latus/core": "^2.0.0",
"@latus/socket": "^2.0.0",
"debug": "4.3.1",
"lodash.flatten": "^4.4.0"
},
"devDependencies": {
"@neutrinojs/airbnb-base": "^9.4.0",
"@neutrinojs/banner": "^9.4.0",
"@neutrinojs/copy": "^9.4.0",
"@neutrinojs/mocha": "^9.4.0",
"@neutrinojs/react": "^9.4.0",
"chai": "4.2.0",
"eslint": "^7",
"eslint-import-resolver-webpack": "0.13.0",
"glob": "7.1.6",
"mocha": "^8",
"neutrino": "^9.4.0",
"source-map-support": "0.5.19",
"webpack": "^4",
"webpack-cli": "^3"
}
}

View File

@ -0,0 +1,57 @@
import {Class, gather, gatherWithLatus} from '@latus/core';
import flatten from 'lodash.flatten';
export default {
hooks: {
'@avocado/traits': gatherWithLatus(
require.context('./traits', false, /\.js$/),
),
'@humus/combat/affinities': () => ({
Void: Class,
Bio: Class,
Stone: Class,
Wood: Class,
Metal: Class,
Air: Class,
Earth: Class,
Fire: Class,
Water: Class,
}),
'@humus/combat/interactions': () => {
const context = require.context('./interactions', false, /from-.*-to.*\.js$/);
return context.keys().map((path) => context(path).default);
},
'@latus/core/starting': (latus) => {
latus.set('%affinities', gather(
latus,
{
type: '@humus/combat/affinities',
},
));
const interactions = flatten(latus.invokeFlat('@humus/combat/interactions'))
.reduce(
(r, interaction) => {
const {harming, harmed} = interaction;
return {
...r,
[harming]: {
...(r[harming] || {}),
[harmed]: [
...((r[harming] || {})[harmed] || []),
interaction,
],
},
};
},
{},
);
latus.set(
'%interactions',
(harmingAffinity, harmedAffinity) => interactions[harmingAffinity]?.[harmedAffinity] || [],
);
},
'@latus/socket/packets': gatherWithLatus(
require.context('./packets', false, /\.js$/),
),
},
};

View File

@ -0,0 +1,57 @@
export default (color) => ({
rate: 0.025,
traits: {
Emitted: {
params: {
alpha: {
start: 1,
end: 0.4,
},
force: [0, 3],
velocity: {
angle: {
min: 338.5,
max: 382.5,
},
magnitude: {
min: 0.7,
max: 0.9,
},
},
scale: {
start: 1,
end: 1.25,
},
transient: false,
ttl: 0.5,
},
},
Existent: {},
Layered: {},
Listed: {},
Perishable: {
params: {
ttl: 10,
},
},
Positioned: {},
Primitive: {
params: {
primitives: [
{
type: 'circle',
radius: 0.5,
line: {
rgba: color,
},
fill: {
rgba: color,
},
},
],
},
},
Roomed: {},
Visible: {},
},
});

View File

@ -0,0 +1,25 @@
import {
buildInvoke,
buildExpression,
} from '@avocado/behavior';
import blood from './blood';
import logParticle from './log-particle';
export default {
harming: 'Stone',
harmed: 'Bio',
actions: {
client: {
type: 'expressions',
expressions: [
buildInvoke(['entity', 'emitParticleJson'], [
logParticle(blood([255, 0, 0]), 5),
]),
buildInvoke(['from', 'playSound'], [
buildExpression(['from', 'harmfulSound']),
]),
],
},
},
};

View File

@ -0,0 +1,31 @@
import {
buildExpression,
buildInvoke,
} from '@avocado/behavior';
export default (json, count) => (
buildInvoke(
['Utility', 'merge'],
[
buildInvoke(
['Utility', 'makeObject'],
[
'count',
buildInvoke(
['Math', 'mul'],
[
buildInvoke(
['Math', 'log10'],
[
buildExpression(['harm', 'amount']),
],
),
count,
],
),
],
),
json,
],
)
);

View File

@ -0,0 +1,36 @@
import {Packet} from '@latus/socket';
export default (latus) => class HarmPacket extends Packet {
static pack(harms) {
const fromType = latus.get('%affinities.fromType');
return harms.map((harm) => {
const {[harm.affinity]: Affinity} = fromType;
// eslint-disable-next-line no-param-reassign
harm.affinity = Affinity.id;
return harm;
});
}
static get data() {
return [
{
amount: 'varuint',
from: 'uint32',
isDamage: 'bool',
affinity: 'uint8',
},
];
}
static unpack(harms) {
const fromId = latus.get('%affinities.fromId');
return harms.map((harm) => {
const {[harm.affinity]: Affinity} = fromId;
// eslint-disable-next-line no-param-reassign
harm.affinity = Affinity.type;
return harm;
});
}
};

View File

@ -0,0 +1,191 @@
import {Vector} from '@avocado/math';
import {StateProperty, Trait} from '@avocado/traits';
import {compose} from '@latus/core';
const decorate = compose(
StateProperty('isHarmful'),
);
export default () => class Harmful extends decorate(Trait) {
#harmSpecs = [];
static behaviorTypes() {
return {
harm: {
type: 'void',
label: 'Harm $1.',
args: [
['other', {
type: 'entity',
}],
],
},
};
}
static defaultParams() {
return {
// TODO newtons
harmKnockback: 500,
harmLock: 0.1,
harmSpecs: [],
};
}
static defaultState() {
return {
isHarmful: true,
};
}
static describeParams() {
return {
harmKnockback: {
type: 'number',
label: 'Knockback force',
},
harmLock: {
type: 'number',
label: 'Harm lockout in seconds',
},
harmSpecs: {
type: 'object',
label: 'Specs',
},
};
}
static describeState() {
return {
isHarmful: {
type: 'bool',
label: 'Is harmful',
},
};
}
// eslint-disable-next-line class-methods-use-this
hooks() {
return {
particles: () => ({
harmful: {
traits: {
Emitted: {
params: {
alpha: {
start: 1,
end: 0.2,
},
rotation: {
start: 0,
add: {
min: -0.5,
max: 0.5,
},
},
scale: {
start: 1,
end: 1.25,
},
ttl: 0.2,
},
},
Existent: {},
Layered: {},
Listed: {},
Positioned: {},
Roomed: {},
Visible: {},
},
},
}),
};
}
listeners() {
const listeners = {};
if ('client' !== process.env.SIDE) {
listeners.collisionStart = (other) => {
this.entity.harm(other);
};
}
return listeners;
}
async load(json) {
await super.load(json);
this.#harmSpecs = this.params.harmSpecs.map((harmSpec) => ({
power: 0,
affinity: 'Void',
variance: 0.2,
...harmSpec,
}));
}
methods() {
return {
harm: (entity) => {
if (!this.entity.isHarmful) {
return;
}
if (!entity.is('Vulnerable')) {
return;
}
if (!entity.isHarmedBy(this.entity)) {
return;
}
if (!entity.ensureVulnerabilityLock(this.entity, this.params.harmLock)) {
return;
}
for (let i = 0; i < this.#harmSpecs.length; ++i) {
const {power, affinity, variance} = this.#harmSpecs[i];
let amount = Math.round(power + power * Math.random() * variance * 2 - variance);
if (power < 0) {
if (amount > 0) {
amount = 0;
}
}
else if (amount < 0) {
amount = 0;
}
const harm = {
amount: Math.abs(amount),
isDamage: power >= 0,
from: this.entity.instanceUuid,
affinity,
};
entity.emit('tookHarm', harm);
}
if (this.params.harmKnockback) {
if (
entity.is('Mobile')
&& entity.is('Positioned')
&& this.entity.is('Positioned')
) {
const unit = Vector.normalize(Vector.sub(
entity.position,
this.entity.position,
));
const knockback = Vector.scale(unit, this.params.harmKnockback);
entity.applyMovement(knockback);
}
}
},
};
}
tick() {
if ('client' !== process.env.SIDE) {
if (this.entity.is('collider')) {
const {isCollidingWith} = this.entity;
for (let i = 0; i < isCollidingWith.length; i++) {
this.entity.harm(isCollidingWith[i]);
}
}
}
}
};

View File

@ -0,0 +1,334 @@
import {
Actions,
compile,
Context,
} from '@avocado/behavior';
import {Trait} from '@avocado/traits';
import flatten from 'lodash.flatten';
export default (latus) => class Vulnerable extends Trait {
#harms = [];
#locks = new Map();
#isInvulnerable = false;
#isNotHarmedBy = [];
acceptHarm(harm) {
const {from, affinity} = harm;
const context = new Context(
{
entity: [this.entity, 'entity'],
from: [from, 'entity'],
harm: [harm, 'harm'],
},
latus,
);
this.constructor.interactions(this.entity, affinity, context, 'client');
this.entity.emit('acceptedHarm', harm);
}
acceptPacket(packet) {
if ('Harm' === packet.constructor.type) {
for (let i = 0; i < packet.data.length; ++i) {
const harm = packet.data[i];
if (this.entity.is('Listed') && this.entity.list) {
harm.from = this.entity.list.findEntity(harm.from);
}
else {
harm.from = undefined;
}
this.acceptHarm(harm);
}
}
}
cleanPackets() {
this.#harms = [];
}
static defaultParams() {
return {
modifiers: undefined,
affinities: [],
};
}
static describeParams() {
return {
modifiers: {
type: 'object',
label: 'Modifiers',
},
affinities: {
type: 'object',
label: 'Types',
},
};
}
destroy() {
this.#locks.clear();
}
static harmTextSize(amount) {
const biggest = 16;
const smallest = biggest / 2;
const step = biggest / 6;
if (amount > 999) {
return biggest;
}
if (amount > 99) {
return smallest + (step * 2);
}
if (amount > 9) {
return smallest + step;
}
return smallest;
}
// eslint-disable-next-line class-methods-use-this
hooks() {
return {
particles: () => ({
harm: {
traits: {
Darkened: {
params: {
isDarkened: false,
},
},
Emitted: {
params: {
alpha: {
start: 1,
end: 0,
},
force: [0, 1],
velocity: {
angle: {
min: 337.5,
max: 382.5,
},
magnitude: {
min: -1.25,
max: -0.75,
},
},
rotation: {
start: 0,
add: {
min: -0.5,
max: 0.5,
},
},
scale: {
start: 1,
end: 1.25,
},
},
},
Existent: {},
Layered: {},
Listed: {},
Positioned: {},
Roomed: {},
Visible: {
state: {
zIndex: 65535,
},
},
},
},
blood: {
traits: {
Emitted: {
params: {
alpha: {
start: 1,
end: 0.4,
},
force: [0, 3],
velocity: {
angle: {
min: 338.5,
max: 382.5,
},
magnitude: {
min: 0.7,
max: 0.9,
},
},
scale: {
start: 1,
end: 1.25,
},
transient: false,
ttl: 0.5,
},
},
Existent: {},
Layered: {},
Listed: {},
Perishable: {
params: {
ttl: 10,
},
},
Positioned: {},
Primitive: {
params: {
primitives: [
{
type: 'circle',
radius: 0.5,
line: {
rgba: [255, 0, 0],
},
fill: {
rgba: [255, 0, 0],
},
},
],
},
},
Roomed: {},
Visible: {},
},
},
}),
};
}
static interactions(harmed, harmingAffinity, context, side) {
const interactions = latus.get('%interactions');
flatten(
harmed.affinities()
.map((harmedAffinity) => interactions(harmingAffinity, harmedAffinity)),
).forEach((interaction) => {
if (interaction.actions[side]) {
const actions = new Actions(compile(interaction.actions[side], latus));
harmed.addTickingPromise(actions.tickingPromise(context));
}
});
}
get isInvulnerable() {
return this.#isInvulnerable;
}
set isInvulnerable(isInvulnerable) {
this.#isInvulnerable = isInvulnerable;
}
listeners() {
return {
acceptedHarm: (harm) => {
const {amount, isDamage} = harm;
const fill = isDamage ? '#FF0000' : '#00FF77';
this.entity.emitParticle('harm', {
traits: {
Textual: {
state: {
text: amount,
textStyle: {
fill,
fontFamily: 'joystix',
fontSize: this.constructor.harmTextSize(amount),
strokeThickness: 2,
},
},
},
},
});
},
isDyingChanged: (_, isDying) => {
this.#isInvulnerable = isDying;
},
tookHarm: (harm) => {
if ('client' !== process.env.SIDE) {
this.#harms.push(harm);
const {from, affinity} = harm;
const context = new Context(
{
entity: [this.entity, 'entity'],
from: [this.entity.list.findEntity(from), 'entity'],
harm: [harm, 'harm'],
},
latus,
);
this.constructor.interactions(this.entity, affinity, context, 'server');
this.markAsDirty();
}
},
};
}
methods() {
return {
affinities: () => this.params.affinities,
ensureVulnerabilityLock: (entity, duration) => {
if (duration <= 0) {
return true;
}
if (this.#locks.has(entity)) {
return false;
}
this.#locks.set(entity, duration);
return true;
},
isHarmedBy: (entity) => {
if (this.#isInvulnerable) {
return false;
}
return -1 === this.#isNotHarmedBy.indexOf(entity);
},
setHarmedBy: (entity) => {
const index = this.#isNotHarmedBy.indexOf(entity);
if (-1 !== index) {
this.#isNotHarmedBy.splice(index, 1);
}
},
setNotHarmedBy: (entity) => {
if (-1 === this.#isNotHarmedBy.indexOf(entity)) {
this.#isNotHarmedBy.push(entity);
}
},
};
}
packets() {
return this.#harms.length > 0
? [['Harm', this.#harms]]
: [];
}
tick(elapsed) {
if ('client' !== process.env.SIDE) {
const it = this.#locks.keys();
for (let current = it.next(); current.done !== true; current = it.next()) {
const {value: key} = current;
const remaining = this.#locks.get(key) - elapsed;
if (remaining <= 0) {
this.#locks.delete(key);
}
else {
this.#locks.set(key, remaining);
}
}
}
}
};

View File

@ -0,0 +1,9 @@
import {expect} from 'chai';
const {name} = require('../package.json');
describe(name, () => {
it('exists', () => {
expect(true).to.be.true;
})
});

View File

@ -0,0 +1,3 @@
const neutrino = require('neutrino');
module.exports = neutrino(require(`${__dirname}/.neutrinorc`)).webpack();

8399
packages/combat/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,97 @@
import {StateProperty, Trait} from '@avocado/traits';
import {compose} from '@latus/core';
const decorate = compose(
StateProperty('lootable', {
track: true,
}),
);
export default () => class Lootable extends decorate(Trait) {
calculateLoot() {
const jsons = [];
const values = Object.values(this.params.table);
for (let i = 0; i < values.length; i++) {
const roll = Math.random() * 100;
const {perc, json} = values[i];
if (perc > roll) {
jsons.push(json);
}
}
return jsons;
}
static defaultParams() {
return {
table: [],
};
}
static defaultState() {
return {
lootable: true,
};
}
static describeParams() {
return {
table: {
type: 'object',
label: 'Loot table',
},
};
}
static describeState() {
return {
lootable: {
type: 'bool',
label: 'Lootable',
},
};
}
hooks() {
const hooks = {};
if ('client' !== process.env.SIDE) {
hooks.died = async () => {
const jsons = this.calculateLoot();
const {position} = this.entity;
const promises = [];
for (let i = 0; i < jsons.length; i++) {
const json = jsons[i];
if (!json.traits) {
json.traits = {};
}
json.traits.Emitted = {
params: {
force: [0, 8],
velocity: {
angle: {
min: 316,
max: 405,
},
magnitude: {
min: 0.7,
max: 0.9,
},
},
position,
transient: false,
ttl: 0.25,
},
};
const stream = this.entity.emitParticleJson(json);
promises.push(new Promise((resolve) => {
stream.onValue((particle) => this.entity.list.addEntity(particle));
stream.onEnd(() => resolve());
}));
}
await Promise.all(promises);
};
}
return hooks;
}
};

View File

@ -0,0 +1,84 @@
import {StateProperty, Trait} from '@avocado/traits';
import {Rectangle, Vector} from '@avocado/math';
import {compose} from '@latus/core';
const decorate = compose(
StateProperty('attraction', {
track: true,
}),
);
export default () => class Magnetic extends decorate(Trait) {
static defaultParams() {
return {
isAttractor: false,
};
}
static defaultState() {
return {
attraction: 0,
};
}
static describeParams() {
return {
isAttractor: {
type: 'bool',
label: 'Is an attractor',
},
};
}
static describeState() {
return {
attraction: {
type: 'number',
label: 'Attraction',
},
};
}
get isAttracted() {
return !this.params.isAttractor;
}
get isAttractor() {
return !!this.params.isAttractor;
}
tick() {
if (!this.params.isAttractor) {
return;
}
const {attraction, layer, position} = this.entity;
if (0 === attraction || !layer) {
return;
}
const query = Rectangle.compose(
Vector.sub(position, [32, 32]),
[64, 64],
);
const entities = layer.visibleEntities(query);
for (let i = 0; i < entities.length; ++i) {
const entity = entities[i];
if (
entity.is('Magnetic')
&& entity.isAttracted
&& entity.is('Positioned')
&& entity.is('Mobile')
) {
const distance = Vector.distance(position, entity.position);
if (distance <= attraction) {
const difference = Vector.sub(position, entity.position);
const unit = Vector.normalize(difference);
const rdiff = Math.max(0.4, 1 - (distance / attraction));
const magnitude = 100 * rdiff;
entity.applyMovement(Vector.scale(unit, magnitude));
}
}
}
}
};