@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
241 lines (201 loc) • 8.2 kB
JavaScript
import RadialMenuView from "../../../../../view/elements/radial/RadialMenu.js";
import { MouseEvents } from "../../../../input/devices/events/MouseEvents.js";
import { TouchEvents } from "../../../../input/devices/events/TouchEvents.js";
import InputController from "../../../../input/ecs/components/InputController.js";
import { SerializationMetadata } from "../../../components/SerializationMetadata.js";
import Entity from "../../../Entity.js";
import { EntityFlags } from "../../../EntityFlags.js";
import GUIElement from "../../GUIElement.js";
import { animateAppearance } from "./AnimateAppearance.js";
import { animateDisappearance } from "./AnimateDisappearance.js";
import { RadialMenuSettings } from "./RadialMenuSettings.js";
/**
*
* @param {EntityComponentDataset} ecd
* @param {Vector2} position Center of the menu
* @param {RadialMenuElementDefinition[]} items Menu options that are to be presented
* @param {number} [innerRadius] see {@link RadialMenuElementDefinition} for more details
* @param {number} [outerRadius] see {@link RadialMenuElementDefinition} for more details
* @param {number} [backdropInnerRadius]
* @param {number} [backdropOuterRadius]
* @param {number} [focusWidth]
* @param {string} [backgroundColor]
* @param {number} [padding] see {@link RadialMenuElementDefinition} for more details
* @param {function(RadialMenuView)} [hook]
* @param {function} [closeCallback]
* @param {boolean} [autoLayout=true]
* @param {string[]} [classList]
* @param {Vector2} [pointerPosition] Used to set initial selection, same as position is not set
* @param {number} [selectionDistance] minimum distance in pixels at which selection registers
* @returns {Entity}
*/
export function makeMenu({
ecd,
position,
items,
innerRadius = RadialMenuSettings.innerRadius,
outerRadius = RadialMenuSettings.outerRadius,
backdropInnerRadius = RadialMenuSettings.backdropInnerRadius,
backdropOuterRadius = RadialMenuSettings.backdropOuterRadius,
focusWidth = RadialMenuSettings.focusWidth,
backgroundColor,
padding = RadialMenuSettings.padding,
hook,
closeCallback,
autoLayout = true,
classList = [],
pointerPosition = position,
selectionDistance = 20
}) {
if (position === undefined) {
throw new Error("Required parameter 'position' is undefined");
}
const origin = position.clone();
const mainView = new RadialMenuView(items, {
innerRadius,
outerRadius,
backdropInnerRadius,
backdropOuterRadius,
focusWidth,
backgroundColor,
padding,
classList
});
if (autoLayout !== false) {
mainView.autoLayout();
}
mainView.render();
mainView.addClass("radial-context-menu");
const mainStyle = mainView.el.style;
mainStyle.cursor = "none";
// position the menu relative to cursor
mainView.position.set(
origin.x - mainView.width / 2,
origin.y - mainView.height / 2
);
if (typeof hook === "function") {
// invoke menu creation hook
try {
hook(mainView);
} catch (e) {
// quietly eat the error
console.error(`Failed to execute hook`, e);
}
}
//play appearance animation
const appearanceAnimationFinished = animateAppearance(mainView, ecd);
const builder = new Entity();
//prevent menu serialization
builder.add(SerializationMetadata.Transient);
function cleanup() {
if (typeof closeCallback === "function") {
closeCallback();
}
clearHandlers();
}
function die() {
builder.sendEvent("radial-menu-died");
//first remove input controller to prevent further interactions
builder.removeComponent(InputController);
//return cursor
mainStyle.cursor = "auto";
//schedule disappearance animation
const disappeared = appearanceAnimationFinished
.then(() => animateDisappearance(mainView, ecd));
//execute cleanup
cleanup();
disappeared.then(function () {
//clear all selection
mainView.resetElementSelection();
//destroy entity
builder.destroy();
});
}
/**
*
* @param {number} x
* @param {number} y
*/
function setMarkerPosition(x, y) {
mainView.linePosition.set(x, y);
const distance = Math.sqrt(x * x + y * y);
if (distance > selectionDistance) {
//figure out angle
let a = Math.atan2(y, x);
while (a < 0) {
// convert negative angle values to positive
a += Math.PI * 2;
}
a = Math.PI * 2 - a;
mainView.selectByAngle(a);
} else {
mainView.resetElementSelection();
}
}
/**
*
* @param {Vector2} target
*/
function moveSelector(target) {
const delta = target.clone().sub(origin);
const distanceFromCenter = delta.length();
const outerMarginMultiplier = 1.3;
const outerMarginConstant = 120;
const maximumAllowedDistance = mainView.focusOuterRadius * outerMarginMultiplier + outerMarginConstant;
if (distanceFromCenter > maximumAllowedDistance) {
//too far, kill self
die();
} else {
setMarkerPosition(delta.x, delta.y);
}
}
moveSelector(pointerPosition);
let selectionMade = false;
/**
*
* @param {Vector2} p
* @param {Event|MouseEvent} event
*/
function performSelection(p, event) {
if (selectionMade) {
//already done
return;
}
selectionMade = true;
try {
mainView.runSelected();
} catch (e) {
console.error("Failed to execute selected menu option: ", e);
}
die();
}
builder.add(GUIElement.fromView(mainView));
builder.add(new InputController([
{
path: "pointer/on/move",
listener: function (p, event) {
//calculate delta from the origin
moveSelector(p);
},
exclusive: true
},
{
path: "pointer/on/up",
listener: performSelection,
exclusive: true,
priority: 10
}
]));
function clearHandlers() {
window.removeEventListener(MouseEvents.Up, performSelection);
window.removeEventListener(TouchEvents.End, performSelection);
}
//workaround for pointer leaving the screen area before selection is made
window.addEventListener(MouseEvents.Up, performSelection);
window.addEventListener(TouchEvents.End, performSelection);
// to simplify usage of this class, we turn on component registration on the builder
builder.setFlag(EntityFlags.RegisterComponents);
// actually build the menu
builder.build(ecd);
return builder;
}