controls tweaks, explorer context menu

This commit is contained in:
Evert Prants 2023-06-09 22:38:53 +03:00
parent c26292e441
commit 919cbba32f
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
16 changed files with 463 additions and 82 deletions

View File

@ -9,6 +9,10 @@
:depth="0"
@select="selectItem"
@toggle="toggleVisibility"
@itemDragStart="(o) => (dragging = o)"
@itemDragOver="(o) => (dragTarget = o)"
@itemDragEnd="dragCompleted"
@operation="explorerOperation"
/>
</SidebarPanel>
@ -22,16 +26,19 @@
<script setup lang="ts">
import { nextTick, onMounted, ref, shallowRef } from 'vue';
import { useEditorEvents } from '../composables/use-editor-events';
import { Editor, SelectionEvent } from '../editor';
import { Editor, EditorEvents, SelectionEvent } from '../editor';
import SidebarPanel from './sidebar/SidebarPanel.vue';
import { GameObject } from '@freeblox/engine';
import { Object3D } from 'three';
import SidebarRow from './sidebar/SidebarRow.vue';
import SidebarForm from './sidebar/SidebarForm.vue';
import { exportToFile } from '../utils/export-file';
const items = shallowRef<GameObject[]>([]);
const selection = shallowRef<GameObject[]>([]);
const selectionMap = ref<string[]>([]);
const dragging = shallowRef<Object3D | undefined>();
const dragTarget = shallowRef<Object3D | undefined>();
const props = defineProps<{
editor: Editor;
@ -87,6 +94,70 @@ const rerenderSelection = () => {
);
};
const clearDrag = () => {
dragging.value = undefined;
dragTarget.value = undefined;
};
const dragCompleted = (object: Object3D) => {
// If the end event doesn't come from the same source
if (object !== dragging.value) return clearDrag();
// If the target was not set
if (!dragTarget.value) return clearDrag();
// If the target is the same object as the source
if (dragTarget.value.id === dragging.value.id) return clearDrag();
// Cannot move a virtual object
if (dragging.value instanceof GameObject && dragging.value.virtual)
return clearDrag();
// Cannot move to a virtual object unless it is world
if (
dragTarget.value instanceof GameObject &&
dragTarget.value.virtual &&
dragTarget.value.objectType !== 'World'
)
return clearDrag();
// Reparent the object
props.editor.events.emit('reparent', {
object: dragging.value,
parent: dragTarget.value,
});
clearDrag();
};
const explorerOperation = (
object: Object3D,
operation: string,
param?: string
) => {
if (['cut', 'copy', 'delete', 'duplicate'].includes(operation)) {
props.editor.events.emit(operation as keyof EditorEvents);
return;
}
if (operation === 'paste') {
props.editor.events.emit('paste', object);
return;
}
if (operation === 'add' && param) {
props.editor.events.emit('instance', {
type: param,
parent: object,
});
return;
}
if (operation === 'export' && object instanceof GameObject) {
const data = JSON.stringify(object.serialize());
exportToFile(data, `${object.name}.json`, 'application/json');
}
};
register('initialized', () => createSceneMap());
register('sceneJoin', () => createSceneMap());
register('sceneLeave', () => createSceneMap());

View File

@ -16,8 +16,10 @@
import { computed, ref } from 'vue';
import { Editor } from '../editor';
import Menu from './menu/Menu.vue';
import { instancableGameObjects } from '@freeblox/engine';
import { WorldFile, instancableGameObjects } from '@freeblox/engine';
import { useEditorEvents } from '../composables/use-editor-events';
import { exportToFile } from '../utils/export-file';
import { readFileToString } from '../utils/read-file';
const currentlyOpen = ref<string | undefined>(undefined);
@ -38,9 +40,14 @@ const fileMenu = [
onClick: () => props.editor.events.emit('reset'),
},
{
id: 'export',
label: 'Export',
onClick: () => {},
id: 'saveas',
label: 'Save as...',
onClick: () => saveLevelToFile(),
},
{
id: 'openfile',
label: 'Open file...',
onClick: () => loadLevelFromFile(),
},
];
@ -119,6 +126,18 @@ const toggleEvent = (id: string, state: boolean, reason: 'user' | 'leave') => {
if (state) currentlyOpen.value = id;
else if (reason === 'user') currentlyOpen.value = undefined;
};
const saveLevelToFile = () => {
const levelData = props.editor.export('Game');
const json = JSON.stringify(levelData);
exportToFile(json, 'level.json', 'application/json');
};
const loadLevelFromFile = async () => {
const data = await readFileToString('application/json');
const obj = JSON.parse(data) as WorldFile;
props.editor.load(obj);
};
</script>
<style lang="scss">

View File

@ -11,16 +11,17 @@
ref="secretInput"
/>
</div>
<button
type="button"
class="form-field-color-button"
@click="secretInput.click()"
>
<button type="button" class="form-field-color-button">
<span
class="form-field-color-preview"
:style="{ backgroundColor: modelValueColor }"
></span
>{{ modelValueColor }}
@click="secretInput.click()"
></span>
<input
v-model="modelValueColor"
class="form-field-color-field"
@blur="changed($event)"
/>
</button>
</div>
</div>
@ -48,6 +49,7 @@ const id = computed(() => `form-${props.name}`);
const changed = ($event: Event) => {
const value = ($event.target as HTMLInputElement).value;
if (!/^#([0-9A-F]{3}){1,2}$/i.test(value)) return;
modelValueColor.value = value;
emit('update', new Color(value));
};
@ -79,7 +81,6 @@ const changed = ($event: Event) => {
&-button {
display: flex;
justify-content: flex-start;
font-family: monospace;
align-items: center;
appearance: none;
padding: 4px;
@ -99,5 +100,16 @@ const changed = ($event: Event) => {
border: 1px solid #ddd;
margin-right: 4px;
}
&-field {
font-family: monospace;
width: 5rem;
border: 0;
background-color: #ffffff;
padding: 8px;
margin: 2px 0;
font-size: 0.75rem;
border-radius: 4px;
}
}
</style>

View File

@ -1,18 +1,35 @@
<template>
<div :class="menuClass">
<div :class="menuClass" :style="menuStyle" @mouseleave="mouseLeave">
<button
type="button"
class="menu-button"
@click="toggle()"
ref="buttonRef"
:disabled="disabled"
@mouseenter="mouseEnter"
v-if="trigger !== 'none'"
>
<slot></slot>
</button>
<div class="menu-dropdown" v-if="isOpen">
<div class="menu-dropdown-inner">
<template v-for="item of items">
<button type="button" class="menu-button" @click="item.onClick">
<Menu
v-if="item.children?.length"
:id="item.id"
:items="item.children"
trigger="hover"
:position="position"
:disabled="item.disabled"
>{{ item.label }}</Menu
>
<button
v-else
type="button"
class="menu-button"
@click="item.onClick"
:disabled="item.disabled"
>
<template v-if="item.component">
<component :is="item.component" v-bind="item"></component>
</template>
@ -30,27 +47,29 @@
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { StyleValue, computed, onMounted, watch } from 'vue';
import { ref } from 'vue';
import { MenuItem } from '../../types/menu.interface';
type ToggleTrigger = 'user' | 'leave';
const buttonRef = ref();
const isOpen = ref(false);
const props = withDefaults(
defineProps<{
id: string;
items?: MenuItem[];
onClick?: () => void;
position?: 'bottom' | 'right';
trigger?: 'click' | 'hover';
position?: 'bottom' | 'right' | 'left';
trigger?: 'click' | 'hover' | 'none';
top?: number;
left?: number;
openSibling?: string;
open?: boolean;
disabled?: boolean;
}>(),
{
position: 'bottom',
trigger: 'click',
open: false,
items: () => [],
}
);
@ -58,6 +77,9 @@ const emit = defineEmits<{
(e: 'toggle', v: boolean, t: ToggleTrigger): void;
}>();
const buttonRef = ref();
const isOpen = ref(props.open);
const toggle = (trigger: ToggleTrigger = 'user') => {
isOpen.value = !isOpen.value;
emit('toggle', isOpen.value, trigger);
@ -69,6 +91,16 @@ const menuClass = computed(() => [
`menu-wrapper--${props.position}`,
]);
const menuStyle = computed(() =>
props.trigger === 'none'
? <StyleValue>{
position: 'absolute',
top: `${props.top}px`,
left: `${props.left}px`,
}
: undefined
);
const mouseEnter = () => {
if (
isOpen.value ||
@ -79,8 +111,14 @@ const mouseEnter = () => {
toggle();
};
const mouseLeave = () => {
if (!isOpen.value || props.trigger !== 'hover') return;
toggle();
};
onMounted(() => {
const handler = (ev: MouseEvent) => {
if (props.trigger === 'none') return;
if ((ev.target as HTMLElement).isEqualNode(buttonRef.value)) return;
if (isOpen.value) toggle();
};
@ -96,6 +134,13 @@ watch(
toggle('leave');
}
);
watch(
() => props.open,
(val) => {
if (val !== isOpen.value) toggle();
}
);
</script>
<style lang="scss">
@ -112,6 +157,11 @@ watch(
left: 100%;
}
&--left > .menu-dropdown {
top: 0;
right: 100%;
}
&--open > .menu-button {
background-color: #e6e6e6;
}
@ -133,6 +183,13 @@ watch(
width: 100%;
text-align: left;
}
& > .menu-wrapper {
&,
.menu-button {
width: 100%;
}
}
}
}

View File

@ -2,15 +2,21 @@
<div class="sidebar-row">
<button
type="button"
:draggable="true"
:class="{
selected: !!selected,
'sidebar-row-button': true,
hidden: !item.visible,
}"
:style="{ paddingLeft: buttonPadding }"
ref="buttonRef"
@click="click($event)"
@dragstart="dragstart"
@dragover="dragover"
@dragend="dragend"
@contextmenu.prevent="openCtxMenu"
>
<span>{{ item.name }}</span>
{{ item.name }}
<button
v-if="!(item as GameObject).virtual"
@click.prevent="toggleVisibility"
@ -19,6 +25,16 @@
<VisibleSvg v-if="item.visible" />
<HiddenSvg v-else />
</button>
<Menu
position="left"
:open="ctxMenuOpen"
trigger="none"
:id="item.uuid"
:items="menuItems"
:top="ctxMenuPos[1]"
:left="ctxMenuPos[0]"
@toggle="ctxMenuOpen = $event"
/>
</button>
<div class="sidebar-row-children">
@ -29,17 +45,24 @@
:depth="depth + 1"
@select="(o, c) => emit('select', o, c)"
@toggle="(o) => emit('toggle', o)"
@operation="(o, c, p) => emit('operation', o, c, p)"
@itemDragStart="(o) => emit('itemDragStart', o)"
@itemDragOver="(o) => emit('itemDragOver', o)"
@itemDragEnd="(o) => emit('itemDragEnd', o)"
></SidebarRow>
</div>
</div>
</template>
<script setup lang="ts">
import { GameObject } from '@freeblox/engine';
import { GameObject, instancableGameObjects } from '@freeblox/engine';
import { Object3D } from 'three';
import { computed } from 'vue';
import { computed, onMounted, ref } from 'vue';
import VisibleSvg from '../../icons/visible.svg.vue';
import HiddenSvg from '../../icons/hidden.svg.vue';
import Menu from '../menu/Menu.vue';
const buttonRef = ref();
const props = defineProps<{
item: Object3D;
@ -51,8 +74,14 @@ const emit = defineEmits<{
(e: 'update'): void;
(e: 'select', item: Object3D, ctrl: boolean): void;
(e: 'toggle', item: Object3D): void;
(e: 'itemDragStart', item: Object3D): void;
(e: 'itemDragOver', item: Object3D): void;
(e: 'itemDragEnd', item: Object3D): void;
(e: 'operation', item: Object3D, op: string, param?: string): void;
}>();
const ctxMenuOpen = ref(false);
const ctxMenuPos = ref([0, 0]);
const selected = computed(() => props.selectionMap?.includes(props.item.uuid));
const buttonPadding = computed(() => props.depth * 8 + 8 + 'px');
const click = ($event: MouseEvent) => {
@ -63,6 +92,79 @@ const filtered = (items: Object3D[]) =>
items.filter((item) => item instanceof GameObject);
const toggleVisibility = () => emit('toggle', props.item);
const dragstart = () => emit('itemDragStart', props.item);
const dragover = () => emit('itemDragOver', props.item);
const dragend = () => emit('itemDragEnd', props.item);
const openCtxMenu = ($event: MouseEvent) => {
if (!selected.value) emit('select', props.item, false);
ctxMenuPos.value = [$event.clientX, $event.clientY];
ctxMenuOpen.value = true;
};
const menuItems = computed(() => [
{
id: 'cut',
label: 'Cut',
onClick: () => emit('operation', props.item, 'cut'),
disabled: (props.item as GameObject).virtual,
},
{
id: 'copy',
label: 'Copy',
onClick: () => emit('operation', props.item, 'copy'),
disabled: (props.item as GameObject).virtual,
},
{
id: 'paste',
label: 'Paste',
onClick: () => emit('operation', props.item, 'paste'),
disabled:
(props.item as GameObject).virtual &&
(props.item as GameObject).objectType !== 'World',
},
{
id: 'duplicate',
label: 'Duplicate',
onClick: () => emit('operation', props.item, 'duplicate'),
disabled: (props.item as GameObject).virtual,
},
{
id: 'delete',
label: 'Delete',
onClick: () => emit('operation', props.item, 'delete'),
disabled: (props.item as GameObject).virtual,
},
{
id: 'add',
label: 'Add...',
disabled: (props.item as GameObject).virtual,
children: Object.keys(instancableGameObjects).map((item) => ({
id: item,
label: item,
onClick: () => emit('operation', props.item, 'add', item),
})),
},
{
id: 'export',
label: 'Export as...',
onClick: () => emit('operation', props.item, 'export'),
disabled: (props.item as GameObject).virtual,
},
]);
onMounted(() => {
const handler = (ev: MouseEvent) => {
if ((ev.target as HTMLElement).isEqualNode(buttonRef.value)) return;
if (ctxMenuOpen.value) ctxMenuOpen.value = false;
};
window.addEventListener('click', handler);
window.addEventListener('contextmenu', handler);
return () => {
window.removeEventListener('click', handler);
window.removeEventListener('contextmenu', handler);
};
});
</script>
<style lang="scss">

View File

@ -46,40 +46,21 @@ class CameraControls extends EventDispatcher {
}
connect() {
this.domElement.ownerDocument.addEventListener(
'mousemove',
this.boundOnMouseMove
);
this.domElement.ownerDocument.addEventListener(
'mousedown',
this.boundOnMouseDown
);
this.domElement.ownerDocument.addEventListener(
'mouseup',
this.boundOnMouseUp
);
this.domElement.ownerDocument.addEventListener('wheel', this.boundOnScroll);
this.domElement.addEventListener('mousemove', this.boundOnMouseMove);
this.domElement.addEventListener('mousedown', this.boundOnMouseDown);
this.domElement.addEventListener('mouseup', this.boundOnMouseUp);
this.domElement.addEventListener('wheel', this.boundOnScroll, {
passive: true,
});
window.addEventListener('keydown', this.boundOnKeyDown);
window.addEventListener('keyup', this.boundOnKeyUp);
}
disconnect() {
this.domElement.ownerDocument.removeEventListener(
'mousemove',
this.boundOnMouseMove
);
this.domElement.ownerDocument.removeEventListener(
'mousedown',
this.boundOnMouseDown
);
this.domElement.ownerDocument.removeEventListener(
'mouseup',
this.boundOnMouseUp
);
this.domElement.ownerDocument.removeEventListener(
'wheel',
this.boundOnScroll
);
this.domElement.removeEventListener('mousemove', this.boundOnMouseMove);
this.domElement.removeEventListener('mousedown', this.boundOnMouseDown);
this.domElement.removeEventListener('mouseup', this.boundOnMouseUp);
this.domElement.removeEventListener('wheel', this.boundOnScroll);
window.removeEventListener('keydown', this.boundOnKeyDown);
window.removeEventListener('keyup', this.boundOnKeyUp);
}
@ -121,6 +102,7 @@ class CameraControls extends EventDispatcher {
moveForward(distance: number) {
const camera = this.camera;
this.rotation.setFromQuaternion(camera.quaternion);
this.look.setFromMatrixColumn(camera.matrix, 0);
this.look.crossVectors(
@ -134,6 +116,7 @@ class CameraControls extends EventDispatcher {
moveUp(distance: number) {
const camera = this.camera;
this.rotation.setFromQuaternion(camera.quaternion);
this.look.copy(camera.up.clone().applyEuler(this.rotation));
camera.position.addScaledVector(this.look, distance);

View File

@ -6,6 +6,7 @@ import {
GameRunner,
Renderer,
LevelComponent,
WorldFile,
} from '@freeblox/engine';
import { EditorEvents } from '../types/events';
import { WorkspaceComponent } from './workspace';
@ -102,6 +103,14 @@ export class Editor extends GameRunner {
return this.level.serializeLevelSave(name);
}
/**
* Deserialize world file and load it into the editor
* @param data World data
*/
public load(data: WorldFile) {
return this.level.deserializeLevelSave(data);
}
/**
* Get selected objects.
*/

View File

@ -55,8 +55,7 @@ export class HistoryComponent extends EngineComponent {
if (object[prop]?.clone) prevState[prop] = object[prop].clone();
if (prop === 'parent') {
object?.removeFromParent();
(value as Object3D)?.add(object);
(value as Object3D)?.attach(object);
} else if (object[prop].copy && prop !== 'material') {
object[prop].copy(value);
} else {

View File

@ -102,20 +102,6 @@ export class WorkspaceComponent extends EngineComponent {
this.background.name = '_background';
this.helpers.name = '_helper';
scene.add(this.background, this.world, this.helpers);
const test = new Brick();
test.position.set(2, 0, 2);
const test2 = new Cylinder();
test2.position.set(0, 0, 2);
const test3 = new Sphere();
test3.position.set(0, 0, 0);
const test4 = new Wedge();
test4.position.set(2, 0, 0);
const test5 = new WedgeCorner();
test5.position.set(4, 0, 0);
const test6 = new WedgeInnerCorner();
test6.position.set(4, 0, 2);
this.world.add(test, test2, test3, test4, test5, test6);
}
private removeFromScene(scene: Object3D) {

View File

@ -5,4 +5,5 @@ export interface MenuItem {
onClick?: () => void;
component?: any;
children?: MenuItem[];
disabled?: boolean;
}

View File

@ -0,0 +1,19 @@
export const exportToFile = (
text: string,
filename: string,
type = 'text/plain'
) => {
const element = document.createElement('a');
element.setAttribute(
'href',
`data:${type};charset=utf-8,${encodeURIComponent(text)}`
);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};

View File

@ -0,0 +1,45 @@
import { h } from 'vue';
export const readString = (file: File, dataUrl = false) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', (event) =>
resolve(event.target?.result as string)
);
reader.addEventListener('error', reject);
if (dataUrl) {
reader.readAsDataURL(file);
} else {
reader.readAsText(file);
}
});
};
export const readFileToString = (allowedTypes?: string) => {
let cleanUpTimer: ReturnType<typeof setTimeout>;
const input = document.createElement('input');
input.type = 'file';
input.style.display = 'none';
if (allowedTypes) input.accept = allowedTypes;
const cleanUp = () => {
clearTimeout(cleanUpTimer);
document.body.removeChild(input);
};
return new Promise<string>((resolve, reject) => {
input.addEventListener('change', () => {
input.files && readString(input.files![0]).then(resolve, reject);
cleanUp();
});
document.body.appendChild(input);
input.click();
cleanUpTimer = setTimeout(() => {
cleanUp();
reject(new Error('Read Timed Out'));
}, 30000);
});
};

View File

@ -13,8 +13,8 @@ import { assetManager } from '../assets/manager';
import { WorldFile } from '../types/world-file';
import { World } from '../gameobjects/world.object';
import { instancableGameObjects } from '../gameobjects';
import { Object3D } from 'three';
import { GameObject } from '../types/game-object';
import { Color, Matrix4, Object3D, Vector3 } from 'three';
import { GameObject, SerializedObject } from '../types/game-object';
import { environmentDefaults } from '../defaults/environment';
/**
@ -63,6 +63,18 @@ export class LevelComponent extends EngineComponent {
const newObject = new ObjectType();
parent.add(newObject);
this.events.emit('sceneJoin', newObject);
return newObject;
}
public deserializeObject(root: SerializedObject, setParent?: Object3D) {
const parent = setParent || this.world;
if (root.objectType === 'World') {
root.children.forEach((entry) => this.recursiveCreate(entry, parent));
return parent;
}
return this.recursiveCreate(root, parent);
}
public serializeLevelSave(name: string): WorldFile {
@ -77,6 +89,50 @@ export class LevelComponent extends EngineComponent {
};
}
public async deserializeLevelSave(save: WorldFile) {
// Reset the world
this.events.emit('reset');
assetManager.freeAll();
// Load all assets
await assetManager.loadAll(save.assets);
// Load environment
this.applyProperties(save.environment, this.environment);
this.events.emit('setEnvironment', this.environment);
// Load world
this.deserializeObject(save.world);
}
private applyProperties(
properties: Record<string, unknown>,
object: Object3D
) {
Object.keys(properties)
.filter((key) => !['children', 'objectType'].includes(key))
.forEach((key) => {
const indexable = object as any;
if (indexable[key]?.fromArray && Array.isArray(properties[key])) {
indexable[key].fromArray(properties[key]);
} else if (indexable[key].isColor) {
indexable[key] = new Color(properties[key] as string);
} else if (indexable[key].copy) {
indexable[key].copy(properties[key]);
} else {
indexable[key] = properties[key];
}
});
}
private recursiveCreate(entry: SerializedObject, setParent?: Object3D) {
const parent = setParent || this.world;
const newObject = this.createObject(entry.objectType, parent);
this.applyProperties(entry, newObject!);
entry.children.forEach((child) => this.recursiveCreate(child, newObject));
return newObject;
}
private bindEvents() {
const changeEvent = (event: ChangeEvent) => {
if (event.applied || !event.object) return;
@ -101,14 +157,24 @@ export class LevelComponent extends EngineComponent {
if (event.applied || !event.object || !event.parent) return;
if (Array.isArray(event.object)) {
event.object.forEach((object) => {
object.removeFromParent();
event.parent.add(object);
event.parent.attach(object);
this.events.emit('change', {
object: object,
property: 'parent',
value: event.parent,
applied: true,
});
});
return;
}
event.object.removeFromParent();
event.parent.add(event.object);
event.parent.attach(event.object);
this.events.emit('change', {
object: event.object,
property: 'parent',
value: event.parent,
applied: true,
});
};
const instanceEvent = (event: InstanceEvent) =>

View File

@ -0,0 +1,6 @@
import { GameObject3D } from '../types/game-object';
export class Group extends GameObject3D {
public objectType = Group.name;
public name = Group.name;
}

View File

@ -6,8 +6,10 @@ import { WedgeCorner } from './wedge-corner.object';
import { WedgeInnerCorner } from './wedge-inner-corner.object';
import { GameObject } from '../types/game-object';
import { Instancable } from '../types/instancable';
import { Group } from './group.object';
export const instancableGameObjects: Record<string, Instancable<GameObject>> = {
[Group.name]: Group,
[Brick.name]: Brick,
[Cylinder.name]: Cylinder,
[Sphere.name]: Sphere,
@ -18,4 +20,4 @@ export const instancableGameObjects: Record<string, Instancable<GameObject>> = {
export * from './environment.object';
export * from './world.object';
export { Cylinder, Brick, Sphere, Wedge, WedgeCorner, WedgeInnerCorner };
export { Group, Cylinder, Brick, Sphere, Wedge, WedgeCorner, WedgeInnerCorner };

View File

@ -42,13 +42,17 @@ export class GameObject extends Object3D {
Object.assign(
object,
keys.reduce<{ [x: string]: unknown }>(
(obj, key) => ({
keys.reduce<{ [x: string]: unknown }>((obj, key) => {
const indexable = this as Record<string, unknown>;
const value = (indexable[key] as any)?.toArray
? (indexable[key] as any).toArray()
: indexable[key];
return {
...obj,
[key]: (this as Record<string, unknown>)[key],
}),
{}
)
[key]: value,
};
}, {})
);
return object;