diff --git a/app/imports/api/creature/creatures/Creatures.js b/app/imports/api/creature/creatures/Creatures.js index 219da0a5..8754e850 100644 --- a/app/imports/api/creature/creatures/Creatures.js +++ b/app/imports/api/creature/creatures/Creatures.js @@ -62,6 +62,34 @@ let CreatureSettingsSchema = new SimpleSchema({ }, }); +let IconGroupSchema = new SimpleSchema({ + name: { + type: String, + max: STORAGE_LIMITS.name, + optional: true, + }, + iconIds: { + type: Array, + max: 4, + defaultValue: [], + }, + 'iconIds.$': { + type: String, + max: STORAGE_LIMITS.variableName, + }, +}); + +let CreatureTabletopSettingsSchema = new SimpleSchema({ + iconGroups: { + type: Array, + defaultValue: [], + max: 10, + }, + 'iconGroups.$': { + type: IconGroupSchema, + }, +}); + let CreatureSchema = new SimpleSchema({ // Strings name: { @@ -177,6 +205,10 @@ let CreatureSchema = new SimpleSchema({ type: SimpleSchema.Integer, optional: true, }, + tabletopSettings: { + type: CreatureTabletopSettingsSchema, + optional: true, + }, // Settings settings: { diff --git a/app/imports/api/creature/log/CreatureLogs.js b/app/imports/api/creature/log/CreatureLogs.js index 9d6c6a79..158c28d4 100644 --- a/app/imports/api/creature/log/CreatureLogs.js +++ b/app/imports/api/creature/log/CreatureLogs.js @@ -8,6 +8,7 @@ import { assertEditPermission } from '/imports/api/creature/creatures/creaturePe import { parse, prettifyParseError } from '/imports/parser/parser'; import resolve, { toString } from '/imports/parser/resolve'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS'; +import { assertUserInTabletop } from '/imports/api/tabletop/methods/shared/tabletopPermissions.js'; const PER_CREATURE_LOG_LIMIT = 100; @@ -42,6 +43,12 @@ let CreatureLogSchema = new SimpleSchema({ regEx: SimpleSchema.RegEx.Id, index: 1, }, + tabletopId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + index: 1, + optional: true, + }, creatureName: { type: String, optional: true, @@ -119,6 +126,7 @@ const insertCreatureLog = new ValidatedMethod({ 'settings.discordWebhook': 1, name: 1, avatarPicture: 1, + tabletop: 1, } }); assertEditPermission(creature, this.userId); @@ -128,7 +136,7 @@ const insertCreatureLog = new ValidatedMethod({ }, }); -export function insertCreatureLogWork({ log, creature, method }) { +export function insertCreatureLogWork({ log, creature, tabletopId, method }) { // Build the new log if (typeof log === 'string') { log = { content: [{ value: log }] }; @@ -142,13 +150,20 @@ export function insertCreatureLogWork({ log, creature, method }) { } }); log.date = new Date(); - + if (tabletopId) log.tabletopId = tabletopId; + if (creature && creature.tabletop) log.tabletopId = creature.tabletop; // Insert it let id = CreatureLogs.insert(log); if (Meteor.isServer) { method?.unblock(); - removeOldLogs(creature._id); - logWebhook({ log, creature }); + if (creature) { + removeOldLogs(creature._id); + logWebhook({ log, creature }); + } + if (log.tabletopId) { + // Todo remove old tabletop logs + // Log webhook if it's different to creature webhook + } } return id; } @@ -170,24 +185,39 @@ const logRoll = new ValidatedMethod({ roll: { type: String, }, + tabletopId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + optional: true, + }, creatureId: { type: String, regEx: SimpleSchema.RegEx.Id, + optional: true, }, }).validator(), - run({ roll, creatureId }) { - const creature = Creatures.findOne(creatureId, { - fields: { - readers: 1, - writers: 1, - owner: 1, - 'settings.discordWebhook': 1, - name: 1, - avatarPicture: 1, - } - }); - assertEditPermission(creature, this.userId); - const variables = CreatureVariables.findOne({ _creatureId: creatureId }); + run({ roll, tabletopId, creatureId }) { + if (!creatureId && !tabletopId) throw new Meteor.Error('no-id', + 'A creature id or tabletop id must be given' + ); + let creature; + if (creatureId) { + creature = Creatures.findOne(creatureId, { + fields: { + readers: 1, + writers: 1, + owner: 1, + 'settings.discordWebhook': 1, + name: 1, + avatarPicture: 1, + } + }); + assertEditPermission(creature, this.userId); + } + if (tabletopId) { + assertUserInTabletop(tabletopId, this.userId); + } + const variables = CreatureVariables.findOne({ _creatureId: creatureId }) || {}; let logContent = [] let parsedResult = undefined; try { @@ -228,7 +258,7 @@ const logRoll = new ValidatedMethod({ date: new Date(), }; - let id = insertCreatureLogWork({ log, creature, method: this }); + let id = insertCreatureLogWork({ log, creature, tabletopId, method: this }); return id; }, diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeAction.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeAction.js index 48a71fb5..5c6d469d 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeAction.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeAction.js @@ -16,8 +16,7 @@ export default function computeAction(computation, node) { } }); prop.resources.itemsConsumed?.forEach(itemConsumed => { - if (!itemConsumed.itemId) return; - if (itemConsumed.available < itemConsumed.quantity?.value) { + if (!itemConsumed.itemId || itemConsumed.available < itemConsumed.quantity?.value) { prop.insufficientResources = true; } }); diff --git a/app/imports/api/engine/loadCreatures.ts b/app/imports/api/engine/loadCreatures.ts index 21e339ae..caa014cc 100644 --- a/app/imports/api/engine/loadCreatures.ts +++ b/app/imports/api/engine/loadCreatures.ts @@ -12,15 +12,17 @@ export const loadedCreatures: Map = new Map(); // creatu export function loadCreature(creatureId: string, subscription: Tracker.Computation) { if (!creatureId) throw 'creatureId is required'; let creature = loadedCreatures.get(creatureId); + if (!creature || !creature.subs.has(subscription)) { + subscription.onStop(() => { + unloadCreature(creatureId, subscription); + }); + } if (creature) { creature.subs.add(subscription); } else { creature = new LoadedCreature(subscription, creatureId); loadedCreatures.set(creatureId, creature); } - subscription.onStop(() => { - unloadCreature(creatureId, subscription); - }); } function unloadCreature(creatureId, subscription) { @@ -188,14 +190,15 @@ export function getPropertyChildren(creatureId, property) { // This propertyId will always appear in the parent of the children if (loadedCreatures.has(creatureId)) { const creature = loadedCreatures.get(creatureId); - const props = []; + if (!creature) return []; + const props: CreatureProperty[] = []; for (const prop of creature.properties.values()) { - if (prop.parent?.id === property._id) { + if (prop.parentId === property._id) { props.push(prop); } } const cloneProps = EJSON.clone(props); - return cloneProps.sort((a, b) => a.order - b.order); + return cloneProps.sort((a, b) => a.left - b.left); } else { return CreatureProperties.find({ 'parent.id': property._id, diff --git a/app/imports/api/tabletop/TabletopMaps.js b/app/imports/api/tabletop/TabletopMaps.js new file mode 100644 index 00000000..76f3e56d --- /dev/null +++ b/app/imports/api/tabletop/TabletopMaps.js @@ -0,0 +1,49 @@ +import SimpleSchema from 'simpl-schema'; +import ChildSchema from '/imports/api/parenting/ChildSchema.js'; + +let TabletopMaps = new Mongo.Collection('tabletopmaps'); + +let TabletopMapschema = new SimpleSchema({ + name: { + type: String, + optional: true, + }, + texture: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + position: { + type: Object, + optional: true, + }, + 'position.x': { + type: Number, + }, + 'position.y': { + type: Number, + }, + width: { + type: Number, + }, + height: { + type: Number, + }, + rotation: { + type: Number, + max: 360, + min: 0, + }, + // If this map was copied from a library map, this ID will be set + libraryMapId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + optional: true, + }, +}); + +const schema = new SimpleSchema({}); +schema.extend(ChildSchema); +schema.extend(TabletopMapschema); +TabletopMaps.attachSchema(schema); + +export default TabletopMaps; diff --git a/app/imports/api/tabletop/TabletopObjects.js b/app/imports/api/tabletop/TabletopObjects.js new file mode 100644 index 00000000..ff42b0e3 --- /dev/null +++ b/app/imports/api/tabletop/TabletopObjects.js @@ -0,0 +1,43 @@ +import SimpleSchema from 'simpl-schema'; +import ChildSchema from '/imports/api/parenting/ChildSchema.js'; + +let TabletopObjects = new Mongo.Collection('tabletopObjects'); + +let TabletopObjectSchema = new SimpleSchema({ + name: { + type: String, + optional: true, + }, + texture: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + position: { + type: Object, + optional: true, + }, + 'position.x': { + type: Number, + }, + 'position.y': { + type: Number, + }, + width: { + type: Number, + }, + height: { + type: Number, + }, + rotation: { + type: Number, + max: 360, + min: 0, + }, +}); + +const schema = new SimpleSchema({}); +schema.extend(ChildSchema); +schema.extend(TabletopObjectSchema); +TabletopObjects.attachSchema(schema); + +export default TabletopObjects; diff --git a/app/imports/api/tabletop/three/OrbitControls.js b/app/imports/api/tabletop/three/OrbitControls.js new file mode 100644 index 00000000..fb6b613c --- /dev/null +++ b/app/imports/api/tabletop/three/OrbitControls.js @@ -0,0 +1,1253 @@ +import { + EventDispatcher, + MOUSE, + Quaternion, + Spherical, + TOUCH, + Vector2, + Vector3 +} from 'three'; + +// https://github.com/mrdoob/three.js/blob/master/examples/jsm/controls/OrbitControls.js +// This set of controls performs orbiting, dollying (zooming), and panning. +// Unlike TrackballControls, it maintains the "up" direction object.up. +// +// Orbit - left mouse / touch: one-finger move +// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish +// Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move + +const _changeEvent = { type: 'change' }; +const _startEvent = { type: 'start' }; +const _endEvent = { type: 'end' }; + +class OrbitControls extends EventDispatcher { + + constructor(object, domElement) { + + super(); + + if (domElement === undefined) console.warn('THREE.OrbitControls: The second parameter "domElement" is now mandatory.'); + if (domElement === document) console.error('THREE.OrbitControls: "document" should not be used as the target "domElement". Please use "renderer.domElement" instead.'); + + this.object = object; + this.domElement = domElement; + this.domElement.style.touchAction = 'none'; // disable touch scroll + + // Set to false to disable this control + this.enabled = true; + + // "target" sets the location of focus, where the object orbits around + this.target = new Vector3(); + + // How far you can dolly in and out ( PerspectiveCamera only ) + this.minDistance = 0; + this.maxDistance = Infinity; + + // How far you can zoom in and out ( OrthographicCamera only ) + this.minZoom = 0; + this.maxZoom = Infinity; + + // How far you can orbit vertically, upper and lower limits. + // Range is 0 to Math.PI radians. + this.minPolarAngle = 0; // radians + this.maxPolarAngle = Math.PI / 2 // radians + + // How far you can orbit horizontally, upper and lower limits. + // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI ) + this.minAzimuthAngle = - Infinity; // radians + this.maxAzimuthAngle = Infinity; // radians + + // Set to true to enable damping (inertia) + // If damping is enabled, you must call controls.update() in your animation loop + this.enableDamping = false; + this.dampingFactor = 0.05; + + // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. + // Set to false to disable zooming + this.enableZoom = true; + this.zoomSpeed = 1.0; + + // Set to false to disable rotating + this.enableRotate = true; + this.rotateSpeed = 1.0; + + // Set to false to disable panning + this.enablePan = true; + this.panSpeed = 1.0; + this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up + this.keyPanSpeed = 7.0; // pixels moved per arrow key push + + // Set to true to automatically rotate around the target + // If auto-rotate is enabled, you must call controls.update() in your animation loop + this.autoRotate = false; + this.autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60 + + // The four arrow keys + this.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' }; + + // Mouse buttons + this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN }; + + // Touch fingers + this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN }; + + // for reset + this.target0 = this.target.clone(); + this.position0 = this.object.position.clone(); + this.zoom0 = this.object.zoom; + + // the target DOM element for key events + this._domElementKeyEvents = null; + + // + // public methods + // + + this.getPolarAngle = function () { + + return spherical.phi; + + }; + + this.getAzimuthalAngle = function () { + + return spherical.theta; + + }; + + this.getDistance = function () { + + return this.object.position.distanceTo(this.target); + + }; + + this.listenToKeyEvents = function (domElement) { + + domElement.addEventListener('keydown', onKeyDown); + this._domElementKeyEvents = domElement; + + }; + + this.saveState = function () { + + scope.target0.copy(scope.target); + scope.position0.copy(scope.object.position); + scope.zoom0 = scope.object.zoom; + + }; + + this.reset = function () { + + scope.target.copy(scope.target0); + scope.object.position.copy(scope.position0); + scope.object.zoom = scope.zoom0; + + scope.object.updateProjectionMatrix(); + scope.dispatchEvent(_changeEvent); + + scope.update(); + + state = STATE.NONE; + + }; + + // this method is exposed, but perhaps it would be better if we can make it private... + this.update = function () { + + const offset = new Vector3(); + + // so camera.up is the orbit axis + const quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0)); + const quatInverse = quat.clone().invert(); + + const lastPosition = new Vector3(); + const lastQuaternion = new Quaternion(); + + const twoPI = 2 * Math.PI; + + return function update() { + + const position = scope.object.position; + + offset.copy(position).sub(scope.target); + + // rotate offset to "y-axis-is-up" space + offset.applyQuaternion(quat); + + // angle from z-axis around y-axis + spherical.setFromVector3(offset); + + if (scope.autoRotate && state === STATE.NONE) { + + rotateLeft(getAutoRotationAngle()); + + } + + if (scope.enableDamping) { + + spherical.theta += sphericalDelta.theta * scope.dampingFactor; + spherical.phi += sphericalDelta.phi * scope.dampingFactor; + + } else { + + spherical.theta += sphericalDelta.theta; + spherical.phi += sphericalDelta.phi; + + } + + // restrict theta to be between desired limits + + let min = scope.minAzimuthAngle; + let max = scope.maxAzimuthAngle; + + if (isFinite(min) && isFinite(max)) { + + if (min < - Math.PI) min += twoPI; else if (min > Math.PI) min -= twoPI; + + if (max < - Math.PI) max += twoPI; else if (max > Math.PI) max -= twoPI; + + if (min <= max) { + + spherical.theta = Math.max(min, Math.min(max, spherical.theta)); + + } else { + + spherical.theta = (spherical.theta > (min + max) / 2) ? + Math.max(min, spherical.theta) : + Math.min(max, spherical.theta); + + } + + } + + // restrict phi to be between desired limits + spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi)); + + spherical.makeSafe(); + + + spherical.radius *= scale; + + // restrict radius to be between desired limits + spherical.radius = Math.max(scope.minDistance, Math.min(scope.maxDistance, spherical.radius)); + + // move target to panned location + + if (scope.enableDamping === true) { + + scope.target.addScaledVector(panOffset, scope.dampingFactor); + + } else { + + scope.target.add(panOffset); + + } + + offset.setFromSpherical(spherical); + + // rotate offset back to "camera-up-vector-is-up" space + offset.applyQuaternion(quatInverse); + + position.copy(scope.target).add(offset); + + scope.object.lookAt(scope.target); + + if (scope.enableDamping === true) { + + sphericalDelta.theta *= (1 - scope.dampingFactor); + sphericalDelta.phi *= (1 - scope.dampingFactor); + + panOffset.multiplyScalar(1 - scope.dampingFactor); + + } else { + + sphericalDelta.set(0, 0, 0); + + panOffset.set(0, 0, 0); + + } + + scale = 1; + + // update condition is: + // min(camera displacement, camera rotation in radians)^2 > EPS + // using small-angle approximation cos(x/2) = 1 - x^2 / 8 + + if (zoomChanged || + lastPosition.distanceToSquared(scope.object.position) > EPS || + 8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS) { + + scope.dispatchEvent(_changeEvent); + + lastPosition.copy(scope.object.position); + lastQuaternion.copy(scope.object.quaternion); + zoomChanged = false; + + return true; + + } + + return false; + + }; + + }(); + + this.dispose = function () { + + scope.domElement.removeEventListener('contextmenu', onContextMenu); + + scope.domElement.removeEventListener('pointerdown', onPointerDown); + scope.domElement.removeEventListener('pointercancel', onPointerCancel); + scope.domElement.removeEventListener('wheel', onMouseWheel); + + scope.domElement.removeEventListener('pointermove', onPointerMove); + scope.domElement.removeEventListener('pointerup', onPointerUp); + + + if (scope._domElementKeyEvents !== null) { + + scope._domElementKeyEvents.removeEventListener('keydown', onKeyDown); + + } + + //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? + + }; + + // + // internals + // + + const scope = this; + + const STATE = { + NONE: - 1, + ROTATE: 0, + DOLLY: 1, + PAN: 2, + TOUCH_ROTATE: 3, + TOUCH_PAN: 4, + TOUCH_DOLLY_PAN: 5, + TOUCH_DOLLY_ROTATE: 6 + }; + + let state = STATE.NONE; + + const EPS = 0.000001; + + // current position in spherical coordinates + const spherical = new Spherical(); + const sphericalDelta = new Spherical(); + + let scale = 1; + const panOffset = new Vector3(); + let zoomChanged = false; + + const rotateStart = new Vector2(); + const rotateEnd = new Vector2(); + const rotateDelta = new Vector2(); + + const panStart = new Vector2(); + const panEnd = new Vector2(); + const panDelta = new Vector2(); + + const dollyStart = new Vector2(); + const dollyEnd = new Vector2(); + const dollyDelta = new Vector2(); + + const pointers = []; + const pointerPositions = {}; + + function getAutoRotationAngle() { + + return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed; + + } + + function getZoomScale() { + + return Math.pow(0.95, scope.zoomSpeed); + + } + + function rotateLeft(angle) { + + sphericalDelta.theta -= angle; + + } + + function rotateUp(angle) { + + sphericalDelta.phi -= angle; + + } + + const panLeft = function () { + + const v = new Vector3(); + + return function panLeft(distance, objectMatrix) { + + v.setFromMatrixColumn(objectMatrix, 0); // get X column of objectMatrix + v.multiplyScalar(- distance); + + panOffset.add(v); + + }; + + }(); + + const panUp = function () { + + const v = new Vector3(); + + return function panUp(distance, objectMatrix) { + + if (scope.screenSpacePanning === true) { + + v.setFromMatrixColumn(objectMatrix, 1); + + } else { + + v.setFromMatrixColumn(objectMatrix, 0); + v.crossVectors(scope.object.up, v); + + } + + v.multiplyScalar(distance); + + panOffset.add(v); + + }; + + }(); + + // deltaX and deltaY are in pixels; right and down are positive + const pan = function () { + + const offset = new Vector3(); + + return function pan(deltaX, deltaY) { + + const element = scope.domElement; + + if (scope.object.isPerspectiveCamera) { + + // perspective + const position = scope.object.position; + offset.copy(position).sub(scope.target); + let targetDistance = offset.length(); + + // half of the fov is center to top of screen + targetDistance *= Math.tan((scope.object.fov / 2) * Math.PI / 180.0); + + // we use only clientHeight here so aspect ratio does not distort speed + panLeft(2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix); + panUp(2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix); + + } else if (scope.object.isOrthographicCamera) { + + // orthographic + panLeft(deltaX * (scope.object.right - scope.object.left) / scope.object.zoom / element.clientWidth, scope.object.matrix); + panUp(deltaY * (scope.object.top - scope.object.bottom) / scope.object.zoom / element.clientHeight, scope.object.matrix); + + } else { + + // camera neither orthographic nor perspective + console.warn('WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.'); + scope.enablePan = false; + + } + + }; + + }(); + + function dollyOut(dollyScale) { + + if (scope.object.isPerspectiveCamera) { + + scale /= dollyScale; + + } else if (scope.object.isOrthographicCamera) { + + scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom * dollyScale)); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.'); + scope.enableZoom = false; + + } + + } + + function dollyIn(dollyScale) { + + if (scope.object.isPerspectiveCamera) { + + scale *= dollyScale; + + } else if (scope.object.isOrthographicCamera) { + + scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom / dollyScale)); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.'); + scope.enableZoom = false; + + } + + } + + // + // event callbacks - update the object state + // + + function handleMouseDownRotate(event) { + + rotateStart.set(event.clientX, event.clientY); + + } + + function handleMouseDownDolly(event) { + + dollyStart.set(event.clientX, event.clientY); + + } + + function handleMouseDownPan(event) { + + panStart.set(event.clientX, event.clientY); + + } + + function handleMouseMoveRotate(event) { + + rotateEnd.set(event.clientX, event.clientY); + + rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed); + + const element = scope.domElement; + + rotateLeft(2 * Math.PI * rotateDelta.x / element.clientHeight); // yes, height + + rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight); + + rotateStart.copy(rotateEnd); + + scope.update(); + + } + + function handleMouseMoveDolly(event) { + + dollyEnd.set(event.clientX, event.clientY); + + dollyDelta.subVectors(dollyEnd, dollyStart); + + if (dollyDelta.y > 0) { + + dollyOut(getZoomScale()); + + } else if (dollyDelta.y < 0) { + + dollyIn(getZoomScale()); + + } + + dollyStart.copy(dollyEnd); + + scope.update(); + + } + + function handleMouseMovePan(event) { + + panEnd.set(event.clientX, event.clientY); + + panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed); + + pan(panDelta.x, panDelta.y); + + panStart.copy(panEnd); + + scope.update(); + + } + + function handleMouseWheel(event) { + + if (event.deltaY < 0) { + + dollyIn(getZoomScale()); + + } else if (event.deltaY > 0) { + + dollyOut(getZoomScale()); + + } + + scope.update(); + + } + + function handleKeyDown(event) { + + let needsUpdate = false; + + switch (event.code) { + + case scope.keys.UP: + pan(0, scope.keyPanSpeed); + needsUpdate = true; + break; + + case scope.keys.BOTTOM: + pan(0, - scope.keyPanSpeed); + needsUpdate = true; + break; + + case scope.keys.LEFT: + pan(scope.keyPanSpeed, 0); + needsUpdate = true; + break; + + case scope.keys.RIGHT: + pan(- scope.keyPanSpeed, 0); + needsUpdate = true; + break; + + } + + if (needsUpdate) { + + // prevent the browser from scrolling on cursor keys + event.preventDefault(); + + scope.update(); + + } + + + } + + function handleTouchStartRotate() { + + if (pointers.length === 1) { + + rotateStart.set(pointers[0].pageX, pointers[0].pageY); + + } else { + + const x = 0.5 * (pointers[0].pageX + pointers[1].pageX); + const y = 0.5 * (pointers[0].pageY + pointers[1].pageY); + + rotateStart.set(x, y); + + } + + } + + function handleTouchStartPan() { + + if (pointers.length === 1) { + + panStart.set(pointers[0].pageX, pointers[0].pageY); + + } else { + + const x = 0.5 * (pointers[0].pageX + pointers[1].pageX); + const y = 0.5 * (pointers[0].pageY + pointers[1].pageY); + + panStart.set(x, y); + + } + + } + + function handleTouchStartDolly() { + + const dx = pointers[0].pageX - pointers[1].pageX; + const dy = pointers[0].pageY - pointers[1].pageY; + + const distance = Math.sqrt(dx * dx + dy * dy); + + dollyStart.set(0, distance); + + } + + function handleTouchStartDollyPan() { + + if (scope.enableZoom) handleTouchStartDolly(); + + if (scope.enablePan) handleTouchStartPan(); + + } + + function handleTouchStartDollyRotate() { + + if (scope.enableZoom) handleTouchStartDolly(); + + if (scope.enableRotate) handleTouchStartRotate(); + + } + + function handleTouchMoveRotate(event) { + + if (pointers.length == 1) { + + rotateEnd.set(event.pageX, event.pageY); + + } else { + + const position = getSecondPointerPosition(event); + + const x = 0.5 * (event.pageX + position.x); + const y = 0.5 * (event.pageY + position.y); + + rotateEnd.set(x, y); + + } + + rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed); + + const element = scope.domElement; + + rotateLeft(2 * Math.PI * rotateDelta.x / element.clientHeight); // yes, height + + rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight); + + rotateStart.copy(rotateEnd); + + } + + function handleTouchMovePan(event) { + + if (pointers.length === 1) { + + panEnd.set(event.pageX, event.pageY); + + } else { + + const position = getSecondPointerPosition(event); + + const x = 0.5 * (event.pageX + position.x); + const y = 0.5 * (event.pageY + position.y); + + panEnd.set(x, y); + + } + + panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed); + + pan(panDelta.x, panDelta.y); + + panStart.copy(panEnd); + + } + + function handleTouchMoveDolly(event) { + + const position = getSecondPointerPosition(event); + + const dx = event.pageX - position.x; + const dy = event.pageY - position.y; + + const distance = Math.sqrt(dx * dx + dy * dy); + + dollyEnd.set(0, distance); + + dollyDelta.set(0, Math.pow(dollyEnd.y / dollyStart.y, scope.zoomSpeed)); + + dollyOut(dollyDelta.y); + + dollyStart.copy(dollyEnd); + + } + + function handleTouchMoveDollyPan(event) { + + if (scope.enableZoom) handleTouchMoveDolly(event); + + if (scope.enablePan) handleTouchMovePan(event); + + } + + function handleTouchMoveDollyRotate(event) { + + if (scope.enableZoom) handleTouchMoveDolly(event); + + if (scope.enableRotate) handleTouchMoveRotate(event); + + } + + // + // event handlers - FSM: listen for events and reset state + // + + function onPointerDown(event) { + + if (scope.enabled === false) return; + + if (pointers.length === 0) { + + scope.domElement.setPointerCapture(event.pointerId); + + scope.domElement.addEventListener('pointermove', onPointerMove); + scope.domElement.addEventListener('pointerup', onPointerUp); + + } + + // + + addPointer(event); + + if (event.pointerType === 'touch') { + + onTouchStart(event); + + } else { + + onMouseDown(event); + + } + + } + + function onPointerMove(event) { + + if (scope.enabled === false) return; + + if (event.pointerType === 'touch') { + + onTouchMove(event); + + } else { + + onMouseMove(event); + + } + + } + + function onPointerUp(event) { + + removePointer(event); + + if (pointers.length === 0) { + + scope.domElement.releasePointerCapture(event.pointerId); + + scope.domElement.removeEventListener('pointermove', onPointerMove); + scope.domElement.removeEventListener('pointerup', onPointerUp); + + } + + scope.dispatchEvent(_endEvent); + + state = STATE.NONE; + + } + + function onPointerCancel(event) { + + removePointer(event); + + } + + function onMouseDown(event) { + + let mouseAction; + + switch (event.button) { + + case 0: + + mouseAction = scope.mouseButtons.LEFT; + break; + + case 1: + + mouseAction = scope.mouseButtons.MIDDLE; + break; + + case 2: + + mouseAction = scope.mouseButtons.RIGHT; + break; + + default: + + mouseAction = - 1; + + } + + switch (mouseAction) { + + case MOUSE.DOLLY: + + if (scope.enableZoom === false) return; + + handleMouseDownDolly(event); + + state = STATE.DOLLY; + + break; + + case MOUSE.ROTATE: + + if (event.ctrlKey || event.metaKey || event.shiftKey) { + + if (scope.enablePan === false) return; + + handleMouseDownPan(event); + + state = STATE.PAN; + + } else { + + if (scope.enableRotate === false) return; + + handleMouseDownRotate(event); + + state = STATE.ROTATE; + + } + + break; + + case MOUSE.PAN: + + if (event.ctrlKey || event.metaKey || event.shiftKey) { + + if (scope.enableRotate === false) return; + + handleMouseDownRotate(event); + + state = STATE.ROTATE; + + } else { + + if (scope.enablePan === false) return; + + handleMouseDownPan(event); + + state = STATE.PAN; + + } + + break; + + default: + + state = STATE.NONE; + + } + + if (state !== STATE.NONE) { + + scope.dispatchEvent(_startEvent); + + } + + } + + function onMouseMove(event) { + + if (scope.enabled === false) return; + + switch (state) { + + case STATE.ROTATE: + + if (scope.enableRotate === false) return; + + handleMouseMoveRotate(event); + + break; + + case STATE.DOLLY: + + if (scope.enableZoom === false) return; + + handleMouseMoveDolly(event); + + break; + + case STATE.PAN: + + if (scope.enablePan === false) return; + + handleMouseMovePan(event); + + break; + + } + + } + + function onMouseWheel(event) { + + if (scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE) return; + + event.preventDefault(); + + scope.dispatchEvent(_startEvent); + + handleMouseWheel(event); + + scope.dispatchEvent(_endEvent); + + } + + function onKeyDown(event) { + + if (scope.enabled === false || scope.enablePan === false) return; + + handleKeyDown(event); + + } + + function onTouchStart(event) { + + trackPointer(event); + + switch (pointers.length) { + + case 1: + + switch (scope.touches.ONE) { + + case TOUCH.ROTATE: + + if (scope.enableRotate === false) return; + + handleTouchStartRotate(); + + state = STATE.TOUCH_ROTATE; + + break; + + case TOUCH.PAN: + + if (scope.enablePan === false) return; + + handleTouchStartPan(); + + state = STATE.TOUCH_PAN; + + break; + + default: + + state = STATE.NONE; + + } + + break; + + case 2: + + switch (scope.touches.TWO) { + + case TOUCH.DOLLY_PAN: + + if (scope.enableZoom === false && scope.enablePan === false) return; + + handleTouchStartDollyPan(); + + state = STATE.TOUCH_DOLLY_PAN; + + break; + + case TOUCH.DOLLY_ROTATE: + + if (scope.enableZoom === false && scope.enableRotate === false) return; + + handleTouchStartDollyRotate(); + + state = STATE.TOUCH_DOLLY_ROTATE; + + break; + + default: + + state = STATE.NONE; + + } + + break; + + default: + + state = STATE.NONE; + + } + + if (state !== STATE.NONE) { + + scope.dispatchEvent(_startEvent); + + } + + } + + function onTouchMove(event) { + + trackPointer(event); + + switch (state) { + + case STATE.TOUCH_ROTATE: + + if (scope.enableRotate === false) return; + + handleTouchMoveRotate(event); + + scope.update(); + + break; + + case STATE.TOUCH_PAN: + + if (scope.enablePan === false) return; + + handleTouchMovePan(event); + + scope.update(); + + break; + + case STATE.TOUCH_DOLLY_PAN: + + if (scope.enableZoom === false && scope.enablePan === false) return; + + handleTouchMoveDollyPan(event); + + scope.update(); + + break; + + case STATE.TOUCH_DOLLY_ROTATE: + + if (scope.enableZoom === false && scope.enableRotate === false) return; + + handleTouchMoveDollyRotate(event); + + scope.update(); + + break; + + default: + + state = STATE.NONE; + + } + + } + + function onContextMenu(event) { + + if (scope.enabled === false) return; + + event.preventDefault(); + + } + + function addPointer(event) { + + pointers.push(event); + + } + + function removePointer(event) { + + delete pointerPositions[event.pointerId]; + + for (let i = 0; i < pointers.length; i++) { + + if (pointers[i].pointerId == event.pointerId) { + + pointers.splice(i, 1); + return; + + } + + } + + } + + function trackPointer(event) { + + let position = pointerPositions[event.pointerId]; + + if (position === undefined) { + + position = new Vector2(); + pointerPositions[event.pointerId] = position; + + } + + position.set(event.pageX, event.pageY); + + } + + function getSecondPointerPosition(event) { + + const pointer = (event.pointerId === pointers[0].pointerId) ? pointers[1] : pointers[0]; + + return pointerPositions[pointer.pointerId]; + + } + + // + + scope.domElement.addEventListener('contextmenu', onContextMenu); + + scope.domElement.addEventListener('pointerdown', onPointerDown); + scope.domElement.addEventListener('pointercancel', onPointerCancel); + scope.domElement.addEventListener('wheel', onMouseWheel, { passive: false }); + + // force an update at start + + this.update(); + + } + +} + + +// This set of controls performs orbiting, dollying (zooming), and panning. +// Unlike TrackballControls, it maintains the "up" direction object.up. +// This is very similar to OrbitControls, another set of touch behavior +// +// Orbit - right mouse, or left mouse + ctrl/meta/shiftKey / touch: two-finger rotate +// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish +// Pan - left mouse, or arrow keys / touch: one-finger move + +class MapControls extends OrbitControls { + + constructor(object, domElement) { + + super(object, domElement); + + this.screenSpacePanning = false; // pan orthogonal to world-space direction camera.up + + this.mouseButtons.LEFT = MOUSE.PAN; + this.mouseButtons.RIGHT = MOUSE.ROTATE; + + this.touches.ONE = TOUCH.PAN; + this.touches.TWO = TOUCH.DOLLY_ROTATE; + + } + +} + +export { OrbitControls, MapControls }; diff --git a/app/imports/client/ui/components/RollPopup.vue b/app/imports/client/ui/components/RollPopup.vue index d7cb25b8..0aaca754 100644 --- a/app/imports/client/ui/components/RollPopup.vue +++ b/app/imports/client/ui/components/RollPopup.vue @@ -12,8 +12,8 @@ @@ -90,6 +90,7 @@ export default { type: Number, default: undefined, }, + noClick: Boolean, }, data(){return { open: false, diff --git a/app/imports/client/ui/creature/character/CharacterSheet.vue b/app/imports/client/ui/creature/character/CharacterSheet.vue index 066316a0..0cb7f7e9 100644 --- a/app/imports/client/ui/creature/character/CharacterSheet.vue +++ b/app/imports/client/ui/creature/character/CharacterSheet.vue @@ -30,18 +30,17 @@
@@ -72,7 +71,7 @@
@@ -144,6 +143,7 @@ import CreatureLogs from '/imports/api/creature/log/CreatureLogs'; import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue'; import CharacterSheetFab from '/imports/client/ui/creature/character/CharacterSheetFab.vue'; import ActionsTab from '/imports/client/ui/creature/character/characterSheetTabs/ActionsTab.vue'; +import CharacterSheetInitiative from '/imports/client/ui/creature/character/CharacterSheetInitiative.vue'; export default { components: { @@ -156,13 +156,16 @@ export default { BuildTab, TreeTab, CharacterSheetFab, + CharacterSheetInitiative, }, props: { creatureId: { type: String, required: true, }, + embedded: Boolean, }, + // @ts-ignore reactiveProvide: { name: 'context', include: ['creatureId', 'editPermission'], @@ -250,4 +253,9 @@ export default { min-height: calc(100vh - 96px); overflow: hidden; } + +.dialog-component .character-sheet .v-window-item { + min-height: unset; + overflow: unset; +} diff --git a/app/imports/client/ui/creature/character/CharacterSheetInitiative.vue b/app/imports/client/ui/creature/character/CharacterSheetInitiative.vue new file mode 100644 index 00000000..ecfc4a3a --- /dev/null +++ b/app/imports/client/ui/creature/character/CharacterSheetInitiative.vue @@ -0,0 +1,46 @@ + + + + + \ No newline at end of file diff --git a/app/imports/client/ui/creature/character/MiniCharacterSheet.vue b/app/imports/client/ui/creature/character/MiniCharacterSheet.vue index ed3509f1..3281a756 100644 --- a/app/imports/client/ui/creature/character/MiniCharacterSheet.vue +++ b/app/imports/client/ui/creature/character/MiniCharacterSheet.vue @@ -1,5 +1,10 @@ diff --git a/app/imports/client/ui/properties/components/PropertyCard.vue b/app/imports/client/ui/properties/components/PropertyCard.vue new file mode 100644 index 00000000..b22ec03e --- /dev/null +++ b/app/imports/client/ui/properties/components/PropertyCard.vue @@ -0,0 +1,30 @@ + + + diff --git a/app/imports/client/ui/properties/components/actions/ActionCard.vue b/app/imports/client/ui/properties/components/actions/ActionCard.vue index 30593eea..1ce3c0c1 100644 --- a/app/imports/client/ui/properties/components/actions/ActionCard.vue +++ b/app/imports/client/ui/properties/components/actions/ActionCard.vue @@ -14,7 +14,7 @@ class="mr-2" :color="model.color || 'primary'" :loading="doActionLoading" - :disabled="model.insufficientResources || !context.editPermission" + :disabled="model.insufficientResources || !context.editPermission || !!targetingError" :roll-text="rollBonus" :name="model.name" :advantage="model.attackRoll && model.attackRoll.advantage" @@ -36,7 +36,7 @@ class="mr-2" :color="model.color || 'primary'" :loading="doActionLoading" - :disabled="model.insufficientResources || !context.editPermission" + :disabled="model.insufficientResources || !context.editPermission || !!targetingError" @click.stop="doAction" > @@ -53,12 +53,20 @@ {{ model.name || propertyName }}
-
- {{ model.actionType }} -
-
- {{ model.usesLeft }} uses +
+ {{ targetingError }}
+
@@ -149,6 +157,10 @@ export default { type: Object, required: true, }, + targets: { + type: Array, + default: undefined, + }, }, data() { return { @@ -185,6 +197,16 @@ export default { actionTypeIcon() { return `$vuetify.icons.${this.model.actionType}`; }, + targetingError(){ + // Can always do an action without a target + if (!this.targets || !this.targets.length) return undefined; + if (this.targets.length > 1 && this.model.target !== 'multipleTargets'){ + return 'Single target'; + } else if (this.model.target === 'self' && this.targets[0] !== this.model.ancestors[0]._id){ + return 'Can only target self'; + } + return undefined; + } }, meteor: { children() { diff --git a/app/imports/client/ui/properties/components/actions/AttributeConsumedView.vue b/app/imports/client/ui/properties/components/actions/AttributeConsumedView.vue index b627e5d3..2d152209 100644 --- a/app/imports/client/ui/properties/components/actions/AttributeConsumedView.vue +++ b/app/imports/client/ui/properties/components/actions/AttributeConsumedView.vue @@ -4,10 +4,11 @@ :class="insufficient && 'error--text'" >
- {{ model.quantity && model.quantity.value }} + {{ model.quantity.value }}
{{ model.statName || model.variableName }}
+
+ ({{ model.available }}) +
diff --git a/app/imports/client/ui/properties/components/actions/ItemConsumedView.vue b/app/imports/client/ui/properties/components/actions/ItemConsumedView.vue index f0eae75a..11be31fb 100644 --- a/app/imports/client/ui/properties/components/actions/ItemConsumedView.vue +++ b/app/imports/client/ui/properties/components/actions/ItemConsumedView.vue @@ -27,28 +27,30 @@ :color="model.itemColor" />
- - + {{ quantity }}
-
- - +
- Select item - + {{ model.itemName }} +
+
+ ({{ model.available }}) +
+ +
+ Select item
-
-
-
- {{ model.value }} / {{ model.total }} -
+ > + {{ model.value }} / {{ model.total }}
-
+ import IncrementMenu from '/imports/client/ui/components/IncrementMenu.vue'; import isDarkColor from '/imports/client/ui/utility/isDarkColor'; +import HealthBarProgress from '/imports/client/ui/properties/components/attributes/HealthBarProgress.vue'; import chroma from 'chroma-js'; export default { components: { - IncrementMenu + IncrementMenu, + HealthBarProgress, }, inject: { theme: { diff --git a/app/imports/client/ui/properties/components/attributes/HealthBarProgress.vue b/app/imports/client/ui/properties/components/attributes/HealthBarProgress.vue new file mode 100644 index 00000000..734c0adb --- /dev/null +++ b/app/imports/client/ui/properties/components/attributes/HealthBarProgress.vue @@ -0,0 +1,69 @@ + + + \ No newline at end of file diff --git a/app/imports/client/ui/router.js b/app/imports/client/ui/router.js index 0d3deb4a..8e987c49 100644 --- a/app/imports/client/ui/router.js +++ b/app/imports/client/ui/router.js @@ -202,9 +202,7 @@ RouterFactory.configure(router => { meta: { title: 'Print Character Sheet', }, - }, - /* Not ready for prime time <3 - { + }, { path: '/tabletops', name: 'tabletops', component: Tabletops, @@ -218,9 +216,7 @@ RouterFactory.configure(router => { rightDrawer: TabletopRightDrawer, }, beforeEnter: ensureLoggedIn, - }, - */ - { + }, { path: '/friends', components: { default: NotImplemented, diff --git a/app/imports/client/ui/styles/cardColors.css b/app/imports/client/ui/styles/cardColors.css index 04f0f52e..2873e9a4 100644 --- a/app/imports/client/ui/styles/cardColors.css +++ b/app/imports/client/ui/styles/cardColors.css @@ -7,14 +7,22 @@ background: #151515; } +.card-background .v-tabs-items.theme--dark { + background: #151515; +} + .theme--light .card-background { background: #f6f6f6; } +.card-background .v-tabs-items.theme--light { + background: #f6f6f6; +} + .theme--dark .card-raised-background { background: #1d1d1d; } .theme--light .card-raised-background { background: #fafafa; -} +} \ No newline at end of file diff --git a/app/imports/client/ui/tabletop/CharacterSheetDialog.vue b/app/imports/client/ui/tabletop/CharacterSheetDialog.vue new file mode 100644 index 00000000..76618c43 --- /dev/null +++ b/app/imports/client/ui/tabletop/CharacterSheetDialog.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/app/imports/client/ui/tabletop/TabletopActionCard.vue b/app/imports/client/ui/tabletop/TabletopActionCard.vue new file mode 100644 index 00000000..bb405a40 --- /dev/null +++ b/app/imports/client/ui/tabletop/TabletopActionCard.vue @@ -0,0 +1,394 @@ + + + + + + + diff --git a/app/imports/client/ui/tabletop/TabletopActionCards.vue b/app/imports/client/ui/tabletop/TabletopActionCards.vue deleted file mode 100644 index cf1f4d6d..00000000 --- a/app/imports/client/ui/tabletop/TabletopActionCards.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - - - diff --git a/app/imports/client/ui/tabletop/TabletopComponent.vue b/app/imports/client/ui/tabletop/TabletopComponent.vue index f65e96d8..cb4786e4 100644 --- a/app/imports/client/ui/tabletop/TabletopComponent.vue +++ b/app/imports/client/ui/tabletop/TabletopComponent.vue @@ -1,56 +1,134 @@ diff --git a/app/imports/client/ui/tabletop/TabletopCreatureCard.vue b/app/imports/client/ui/tabletop/TabletopCreatureCard.vue index 80c1561b..43c55444 100644 --- a/app/imports/client/ui/tabletop/TabletopCreatureCard.vue +++ b/app/imports/client/ui/tabletop/TabletopCreatureCard.vue @@ -1,37 +1,146 @@ + + diff --git a/app/imports/client/ui/tabletop/TabletopLog.vue b/app/imports/client/ui/tabletop/TabletopLog.vue index e631307d..5775dfcc 100644 --- a/app/imports/client/ui/tabletop/TabletopLog.vue +++ b/app/imports/client/ui/tabletop/TabletopLog.vue @@ -1,52 +1,38 @@ diff --git a/app/imports/client/ui/tabletop/TabletopMap.vue b/app/imports/client/ui/tabletop/TabletopMap.vue index 605da292..87eb8397 100644 --- a/app/imports/client/ui/tabletop/TabletopMap.vue +++ b/app/imports/client/ui/tabletop/TabletopMap.vue @@ -1,11 +1,134 @@ -