@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
390 lines (304 loc) • 10.6 kB
JavaScript
import { array_set_diff } from "../../../core/collection/array/array_set_diff.js";
import List from "../../../core/collection/list/List.js";
import Vector2 from "../../../core/geom/Vector2.js";
import SVG from "../../SVG.js";
import View from "../../View.js";
import EmptyView from "../EmptyView.js";
import { makeDonut } from "./makeDonut.js";
import RadialMenuElement from "./RadialMenuElement.js";
const PI2 = Math.PI * 2;
const DEFAULT_BACKGROUND_COLOR = "rgba(0,0,0,0.6)";
class RadialMenu extends View {
/**
*
* @param {RadialMenuElementDefinition[]} items
* @param {number} [padding=0]
* @param {number} [outerRadius=150]
* @param {number} [innerRadius=50]
* @param {number} [focusWidth]
* @param {number} [backdropInnerRadius]
* @param {number} [backdropOuterRadius]
* @param {string} [backgroundColor] CSSColor string
* @param {string[]} [classList]
* @constructor
*/
constructor(items, {
padding = 0,
outerRadius = 150,
innerRadius = 50,
focusWidth = 20,
backdropInnerRadius,
backdropOuterRadius,
backgroundColor = DEFAULT_BACKGROUND_COLOR,
classList = []
}) {
super();
/**
*
* @type {List<RadialMenuElement>}
*/
this.selected = new List();
/**
*
* @type {Array.<RadialMenuElement>}
*/
this.elements = [];
/**
*
* @type {number}
*/
this.firstElementOffset = 0;
/**
*
* @type {number}
*/
this.padding = padding;
/**
*
* @type {number}
*/
this.outerRadius = outerRadius;
/**
*
* @type {number}
*/
this.innerRadius = innerRadius;
if (backdropInnerRadius === undefined) {
backdropInnerRadius = innerRadius;
}
if (backdropOuterRadius === undefined) {
backdropOuterRadius = outerRadius + 10;
}
/**
* @type {number}
*/
this.backdropInnerRadius = backdropInnerRadius;
/**
* @type {number}
*/
this.backdropOuterRadius = backdropOuterRadius;
/**
*
* @type {number}
*/
this.focusOuterRadius = this.outerRadius + focusWidth;
/**
*
* @type {number}
*/
this.focusInnerRadius = this.innerRadius;
/**
*
* @type {number}
*/
this.width = this.focusOuterRadius * 2;
/**
*
* @type {number}
*/
this.height = this.focusOuterRadius * 2;
const el = this.el = document.createElement("div");
el.classList.add("ui-radial-menu");
classList.forEach(c => this.addClass(c));
el.style.position = "absolute";
el.style.overflow = "visible";
this.size.set(this.width, this.height);
const svgElDonut = SVG.createElement("svg");
svgElDonut.classList.add("backdrop");
svgElDonut.style.overflow = "visible";
svgElDonut.setAttribute("width", this.width);
svgElDonut.setAttribute("height", this.height);
//line to give feedback
const elLine = SVG.createElement("line");
elLine.classList.add("pointer-line");
elLine.style.stroke = "rgba(255,255,255,0.6)";
elLine.style.strokeWidth = "5";
elLine.style.strokeLinecap = "round";
el.appendChild(svgElDonut);
const elDonut = makeDonut(this.backdropOuterRadius, this.backdropInnerRadius, this.focusOuterRadius, this.focusOuterRadius);
elDonut.setAttribute("fill", backgroundColor);
svgElDonut.appendChild(elDonut);
const vElementContainer = new EmptyView({ classList: ["elements"] });
vElementContainer.position.set(this.focusOuterRadius, this.focusOuterRadius);
this.addChild(vElementContainer);
this.vElementContainer = vElementContainer;
function moveLineEnd(x, y) {
elLine.setAttribute("x2", vElementContainer.position.x + x);
elLine.setAttribute("y2", vElementContainer.position.y + y);
}
this.linePosition = new Vector2(0, 0);
this.linePosition.onChanged.add(moveLineEnd);
elLine.setAttribute("x1", vElementContainer.position.x);
elLine.setAttribute("y1", vElementContainer.position.y);
//initialize line to 0 length in the middle
moveLineEnd(0, 0);
this.init(items);
const vLineContainer = new EmptyView({ classList: ["pointer-line"] });
const elSvgLine = SVG.createElement("svg");
vLineContainer.el.appendChild(elSvgLine);
elSvgLine.appendChild(elLine);
this.addChild(vLineContainer);
}
render() {
this.elements.forEach(function (el) {
el.render();
});
}
link() {
super.link();
this.render();
}
computeTotalShareValue() {
return this.elements.reduce(function (prev, element) {
return prev + element.description.share;
}, 0);
}
/**
*
* @param {RadialMenuElementDefinition[]} items
*/
init(items) {
const self = this;
const n = items.length;
const padding = n > 1 ? this.padding : 0;
this.elements = [];
for (let i = 0; i < n; i++) {
/**
*
* @type {RadialMenuElementDefinition}
*/
const item = items[i];
if (!item.isRadialMenuElementDefinition) {
throw new Error(`Supplied element is not RadialMenuElementDefinition`);
}
item.outerRadius = self.outerRadius;
item.innerRadius = self.innerRadius;
item.padding = padding;
const element = new RadialMenuElement(item);
this.elements.push(element);
this.vElementContainer.addChild(element);
}
}
autoLayout() {
const numElements = this.elements.length;
if (numElements === 1) {
const first = this.elements[0];
first.description.share = 0.33;
} else {
//normalize share values
this.normalizeElementShares();
}
if (numElements > 0) {
//set first element position so that it appears at the top
const firstElement = this.elements[0];
firstElement.description.offset = -(0.25 + firstElement.description.share / 2);
}
this.updatePositions();
}
normalizeElementShares() {
const shareValue = this.computeTotalShareValue();
this.elements.forEach(function (element) {
element.description.share /= shareValue;
});
}
normalizeOffsetsSequentially() {
const elements = this.elements;
if (elements.length === 0) {
return;
}
const firstElement = elements[0];
let offset = this.firstElementOffset + firstElement.description.offset;
elements.forEach(function (element) {
const share = element.description.share;
element.description.offset = offset;
offset += share;
});
}
updatePositions() {
this.normalizeOffsetsSequentially();
//render
this.elements.forEach(function (element, index) {
element.render();
});
}
/**
*
* @param {RadialMenuElement} el
* @param {boolean} flag
*/
setElementSelection(el, flag) {
const selected = this.selected;
const index = selected.indexOf(el);
if (flag && index < 0) {
// should be selected but is not
selected.add(el);
el.outerRadius = this.focusOuterRadius;
el.innerRadius = this.focusInnerRadius;
const onSelected = el.description.onSelected;
if (typeof onSelected === "function") {
// invoke callback
onSelected();
}
} else if (!flag && index >= 0) {
// should not be selected, but currently is
selected.remove(index);
el.outerRadius = this.outerRadius;
el.innerRadius = this.innerRadius;
const onDeSelected = el.description.onDeSelected;
if (typeof onDeSelected === "function") {
// invoke callback
onDeSelected();
}
}
}
resetElementSelection() {
const self = this;
const selected = this.selected.asArray().slice();
for (const el of selected) {
self.setElementSelection(el, false);
}
}
/**
*
* @param {number} angle
*/
selectByAngle(angle) {
const na = 1 - angle / PI2;
//pick element that fits the angle
const elements = this.elements;
const elementCount = elements.length;
const selected = [];
for (let i = 0; i < elementCount; i++) {
const el = elements[i];
/**
*
* @type {RadialMenuElementDefinition}
*/
const description = el.description;
//normalize offset
let no = description.offset;
while (no < 0) {
no += 1;
}
let s0 = no;
let s1 = no + description.share;
if ((s0 <= na && s1 > na) || (s1 > 1 && (s1 % 1) > na)) {
selected.push(el);
}
}
const diff = array_set_diff(this.selected.asArray(), selected);
const removals = diff.uniqueA;
const additions = diff.uniqueB;
removals.forEach(el => this.setElementSelection(el, false));
additions.forEach(el => this.setElementSelection(el, true));
}
runSelected() {
this.selected.forEach(function (el) {
const action = el.description.action;
if (typeof action === "function") {
action();
}
});
}
}
export default RadialMenu;