controls tweaks, explorer context menu
This commit is contained in:
parent
c26292e441
commit
919cbba32f
@ -9,6 +9,10 @@
|
|||||||
:depth="0"
|
:depth="0"
|
||||||
@select="selectItem"
|
@select="selectItem"
|
||||||
@toggle="toggleVisibility"
|
@toggle="toggleVisibility"
|
||||||
|
@itemDragStart="(o) => (dragging = o)"
|
||||||
|
@itemDragOver="(o) => (dragTarget = o)"
|
||||||
|
@itemDragEnd="dragCompleted"
|
||||||
|
@operation="explorerOperation"
|
||||||
/>
|
/>
|
||||||
</SidebarPanel>
|
</SidebarPanel>
|
||||||
|
|
||||||
@ -22,16 +26,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onMounted, ref, shallowRef } from 'vue';
|
import { nextTick, onMounted, ref, shallowRef } from 'vue';
|
||||||
import { useEditorEvents } from '../composables/use-editor-events';
|
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 SidebarPanel from './sidebar/SidebarPanel.vue';
|
||||||
import { GameObject } from '@freeblox/engine';
|
import { GameObject } from '@freeblox/engine';
|
||||||
import { Object3D } from 'three';
|
import { Object3D } from 'three';
|
||||||
import SidebarRow from './sidebar/SidebarRow.vue';
|
import SidebarRow from './sidebar/SidebarRow.vue';
|
||||||
import SidebarForm from './sidebar/SidebarForm.vue';
|
import SidebarForm from './sidebar/SidebarForm.vue';
|
||||||
|
import { exportToFile } from '../utils/export-file';
|
||||||
|
|
||||||
const items = shallowRef<GameObject[]>([]);
|
const items = shallowRef<GameObject[]>([]);
|
||||||
const selection = shallowRef<GameObject[]>([]);
|
const selection = shallowRef<GameObject[]>([]);
|
||||||
const selectionMap = ref<string[]>([]);
|
const selectionMap = ref<string[]>([]);
|
||||||
|
const dragging = shallowRef<Object3D | undefined>();
|
||||||
|
const dragTarget = shallowRef<Object3D | undefined>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
editor: Editor;
|
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('initialized', () => createSceneMap());
|
||||||
register('sceneJoin', () => createSceneMap());
|
register('sceneJoin', () => createSceneMap());
|
||||||
register('sceneLeave', () => createSceneMap());
|
register('sceneLeave', () => createSceneMap());
|
||||||
|
@ -16,8 +16,10 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { Editor } from '../editor';
|
import { Editor } from '../editor';
|
||||||
import Menu from './menu/Menu.vue';
|
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 { useEditorEvents } from '../composables/use-editor-events';
|
||||||
|
import { exportToFile } from '../utils/export-file';
|
||||||
|
import { readFileToString } from '../utils/read-file';
|
||||||
|
|
||||||
const currentlyOpen = ref<string | undefined>(undefined);
|
const currentlyOpen = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
@ -38,9 +40,14 @@ const fileMenu = [
|
|||||||
onClick: () => props.editor.events.emit('reset'),
|
onClick: () => props.editor.events.emit('reset'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'export',
|
id: 'saveas',
|
||||||
label: 'Export',
|
label: 'Save as...',
|
||||||
onClick: () => {},
|
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;
|
if (state) currentlyOpen.value = id;
|
||||||
else if (reason === 'user') currentlyOpen.value = undefined;
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@ -11,16 +11,17 @@
|
|||||||
ref="secretInput"
|
ref="secretInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" class="form-field-color-button">
|
||||||
type="button"
|
|
||||||
class="form-field-color-button"
|
|
||||||
@click="secretInput.click()"
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
class="form-field-color-preview"
|
class="form-field-color-preview"
|
||||||
:style="{ backgroundColor: modelValueColor }"
|
:style="{ backgroundColor: modelValueColor }"
|
||||||
></span
|
@click="secretInput.click()"
|
||||||
>{{ modelValueColor }}
|
></span>
|
||||||
|
<input
|
||||||
|
v-model="modelValueColor"
|
||||||
|
class="form-field-color-field"
|
||||||
|
@blur="changed($event)"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -48,6 +49,7 @@ const id = computed(() => `form-${props.name}`);
|
|||||||
|
|
||||||
const changed = ($event: Event) => {
|
const changed = ($event: Event) => {
|
||||||
const value = ($event.target as HTMLInputElement).value;
|
const value = ($event.target as HTMLInputElement).value;
|
||||||
|
if (!/^#([0-9A-F]{3}){1,2}$/i.test(value)) return;
|
||||||
modelValueColor.value = value;
|
modelValueColor.value = value;
|
||||||
emit('update', new Color(value));
|
emit('update', new Color(value));
|
||||||
};
|
};
|
||||||
@ -79,7 +81,6 @@ const changed = ($event: Event) => {
|
|||||||
&-button {
|
&-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
font-family: monospace;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
@ -99,5 +100,16 @@ const changed = ($event: Event) => {
|
|||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
margin-right: 4px;
|
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>
|
</style>
|
||||||
|
@ -1,18 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="menuClass">
|
<div :class="menuClass" :style="menuStyle" @mouseleave="mouseLeave">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="menu-button"
|
class="menu-button"
|
||||||
@click="toggle()"
|
@click="toggle()"
|
||||||
ref="buttonRef"
|
ref="buttonRef"
|
||||||
|
:disabled="disabled"
|
||||||
@mouseenter="mouseEnter"
|
@mouseenter="mouseEnter"
|
||||||
|
v-if="trigger !== 'none'"
|
||||||
>
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</button>
|
</button>
|
||||||
<div class="menu-dropdown" v-if="isOpen">
|
<div class="menu-dropdown" v-if="isOpen">
|
||||||
<div class="menu-dropdown-inner">
|
<div class="menu-dropdown-inner">
|
||||||
<template v-for="item of items">
|
<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">
|
<template v-if="item.component">
|
||||||
<component :is="item.component" v-bind="item"></component>
|
<component :is="item.component" v-bind="item"></component>
|
||||||
</template>
|
</template>
|
||||||
@ -30,27 +47,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue';
|
import { StyleValue, computed, onMounted, watch } from 'vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { MenuItem } from '../../types/menu.interface';
|
import { MenuItem } from '../../types/menu.interface';
|
||||||
|
|
||||||
type ToggleTrigger = 'user' | 'leave';
|
type ToggleTrigger = 'user' | 'leave';
|
||||||
|
|
||||||
const buttonRef = ref();
|
|
||||||
const isOpen = ref(false);
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id: string;
|
id: string;
|
||||||
items?: MenuItem[];
|
items?: MenuItem[];
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
position?: 'bottom' | 'right';
|
position?: 'bottom' | 'right' | 'left';
|
||||||
trigger?: 'click' | 'hover';
|
trigger?: 'click' | 'hover' | 'none';
|
||||||
|
top?: number;
|
||||||
|
left?: number;
|
||||||
openSibling?: string;
|
openSibling?: string;
|
||||||
|
open?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
trigger: 'click',
|
trigger: 'click',
|
||||||
|
open: false,
|
||||||
items: () => [],
|
items: () => [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -58,6 +77,9 @@ const emit = defineEmits<{
|
|||||||
(e: 'toggle', v: boolean, t: ToggleTrigger): void;
|
(e: 'toggle', v: boolean, t: ToggleTrigger): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const buttonRef = ref();
|
||||||
|
const isOpen = ref(props.open);
|
||||||
|
|
||||||
const toggle = (trigger: ToggleTrigger = 'user') => {
|
const toggle = (trigger: ToggleTrigger = 'user') => {
|
||||||
isOpen.value = !isOpen.value;
|
isOpen.value = !isOpen.value;
|
||||||
emit('toggle', isOpen.value, trigger);
|
emit('toggle', isOpen.value, trigger);
|
||||||
@ -69,6 +91,16 @@ const menuClass = computed(() => [
|
|||||||
`menu-wrapper--${props.position}`,
|
`menu-wrapper--${props.position}`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const menuStyle = computed(() =>
|
||||||
|
props.trigger === 'none'
|
||||||
|
? <StyleValue>{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${props.top}px`,
|
||||||
|
left: `${props.left}px`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
const mouseEnter = () => {
|
const mouseEnter = () => {
|
||||||
if (
|
if (
|
||||||
isOpen.value ||
|
isOpen.value ||
|
||||||
@ -79,8 +111,14 @@ const mouseEnter = () => {
|
|||||||
toggle();
|
toggle();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mouseLeave = () => {
|
||||||
|
if (!isOpen.value || props.trigger !== 'hover') return;
|
||||||
|
toggle();
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const handler = (ev: MouseEvent) => {
|
const handler = (ev: MouseEvent) => {
|
||||||
|
if (props.trigger === 'none') return;
|
||||||
if ((ev.target as HTMLElement).isEqualNode(buttonRef.value)) return;
|
if ((ev.target as HTMLElement).isEqualNode(buttonRef.value)) return;
|
||||||
if (isOpen.value) toggle();
|
if (isOpen.value) toggle();
|
||||||
};
|
};
|
||||||
@ -96,6 +134,13 @@ watch(
|
|||||||
toggle('leave');
|
toggle('leave');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(val) => {
|
||||||
|
if (val !== isOpen.value) toggle();
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@ -112,6 +157,11 @@ watch(
|
|||||||
left: 100%;
|
left: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--left > .menu-dropdown {
|
||||||
|
top: 0;
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
&--open > .menu-button {
|
&--open > .menu-button {
|
||||||
background-color: #e6e6e6;
|
background-color: #e6e6e6;
|
||||||
}
|
}
|
||||||
@ -133,6 +183,13 @@ watch(
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > .menu-wrapper {
|
||||||
|
&,
|
||||||
|
.menu-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,15 +2,21 @@
|
|||||||
<div class="sidebar-row">
|
<div class="sidebar-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
:draggable="true"
|
||||||
:class="{
|
:class="{
|
||||||
selected: !!selected,
|
selected: !!selected,
|
||||||
'sidebar-row-button': true,
|
'sidebar-row-button': true,
|
||||||
hidden: !item.visible,
|
hidden: !item.visible,
|
||||||
}"
|
}"
|
||||||
:style="{ paddingLeft: buttonPadding }"
|
:style="{ paddingLeft: buttonPadding }"
|
||||||
|
ref="buttonRef"
|
||||||
@click="click($event)"
|
@click="click($event)"
|
||||||
|
@dragstart="dragstart"
|
||||||
|
@dragover="dragover"
|
||||||
|
@dragend="dragend"
|
||||||
|
@contextmenu.prevent="openCtxMenu"
|
||||||
>
|
>
|
||||||
<span>{{ item.name }}</span>
|
{{ item.name }}
|
||||||
<button
|
<button
|
||||||
v-if="!(item as GameObject).virtual"
|
v-if="!(item as GameObject).virtual"
|
||||||
@click.prevent="toggleVisibility"
|
@click.prevent="toggleVisibility"
|
||||||
@ -19,6 +25,16 @@
|
|||||||
<VisibleSvg v-if="item.visible" />
|
<VisibleSvg v-if="item.visible" />
|
||||||
<HiddenSvg v-else />
|
<HiddenSvg v-else />
|
||||||
</button>
|
</button>
|
||||||
|
<Menu
|
||||||
|
position="left"
|
||||||
|
:open="ctxMenuOpen"
|
||||||
|
trigger="none"
|
||||||
|
:id="item.uuid"
|
||||||
|
:items="menuItems"
|
||||||
|
:top="ctxMenuPos[1]"
|
||||||
|
:left="ctxMenuPos[0]"
|
||||||
|
@toggle="ctxMenuOpen = $event"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="sidebar-row-children">
|
<div class="sidebar-row-children">
|
||||||
@ -29,17 +45,24 @@
|
|||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
@select="(o, c) => emit('select', o, c)"
|
@select="(o, c) => emit('select', o, c)"
|
||||||
@toggle="(o) => emit('toggle', o)"
|
@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>
|
></SidebarRow>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GameObject } from '@freeblox/engine';
|
import { GameObject, instancableGameObjects } from '@freeblox/engine';
|
||||||
import { Object3D } from 'three';
|
import { Object3D } from 'three';
|
||||||
import { computed } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import VisibleSvg from '../../icons/visible.svg.vue';
|
import VisibleSvg from '../../icons/visible.svg.vue';
|
||||||
import HiddenSvg from '../../icons/hidden.svg.vue';
|
import HiddenSvg from '../../icons/hidden.svg.vue';
|
||||||
|
import Menu from '../menu/Menu.vue';
|
||||||
|
|
||||||
|
const buttonRef = ref();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: Object3D;
|
item: Object3D;
|
||||||
@ -51,8 +74,14 @@ const emit = defineEmits<{
|
|||||||
(e: 'update'): void;
|
(e: 'update'): void;
|
||||||
(e: 'select', item: Object3D, ctrl: boolean): void;
|
(e: 'select', item: Object3D, ctrl: boolean): void;
|
||||||
(e: 'toggle', item: Object3D): 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 selected = computed(() => props.selectionMap?.includes(props.item.uuid));
|
||||||
const buttonPadding = computed(() => props.depth * 8 + 8 + 'px');
|
const buttonPadding = computed(() => props.depth * 8 + 8 + 'px');
|
||||||
const click = ($event: MouseEvent) => {
|
const click = ($event: MouseEvent) => {
|
||||||
@ -63,6 +92,79 @@ const filtered = (items: Object3D[]) =>
|
|||||||
items.filter((item) => item instanceof GameObject);
|
items.filter((item) => item instanceof GameObject);
|
||||||
|
|
||||||
const toggleVisibility = () => emit('toggle', props.item);
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@ -46,40 +46,21 @@ class CameraControls extends EventDispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.domElement.ownerDocument.addEventListener(
|
this.domElement.addEventListener('mousemove', this.boundOnMouseMove);
|
||||||
'mousemove',
|
this.domElement.addEventListener('mousedown', this.boundOnMouseDown);
|
||||||
this.boundOnMouseMove
|
this.domElement.addEventListener('mouseup', this.boundOnMouseUp);
|
||||||
);
|
this.domElement.addEventListener('wheel', this.boundOnScroll, {
|
||||||
this.domElement.ownerDocument.addEventListener(
|
passive: true,
|
||||||
'mousedown',
|
});
|
||||||
this.boundOnMouseDown
|
|
||||||
);
|
|
||||||
this.domElement.ownerDocument.addEventListener(
|
|
||||||
'mouseup',
|
|
||||||
this.boundOnMouseUp
|
|
||||||
);
|
|
||||||
this.domElement.ownerDocument.addEventListener('wheel', this.boundOnScroll);
|
|
||||||
window.addEventListener('keydown', this.boundOnKeyDown);
|
window.addEventListener('keydown', this.boundOnKeyDown);
|
||||||
window.addEventListener('keyup', this.boundOnKeyUp);
|
window.addEventListener('keyup', this.boundOnKeyUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this.domElement.ownerDocument.removeEventListener(
|
this.domElement.removeEventListener('mousemove', this.boundOnMouseMove);
|
||||||
'mousemove',
|
this.domElement.removeEventListener('mousedown', this.boundOnMouseDown);
|
||||||
this.boundOnMouseMove
|
this.domElement.removeEventListener('mouseup', this.boundOnMouseUp);
|
||||||
);
|
this.domElement.removeEventListener('wheel', this.boundOnScroll);
|
||||||
this.domElement.ownerDocument.removeEventListener(
|
|
||||||
'mousedown',
|
|
||||||
this.boundOnMouseDown
|
|
||||||
);
|
|
||||||
this.domElement.ownerDocument.removeEventListener(
|
|
||||||
'mouseup',
|
|
||||||
this.boundOnMouseUp
|
|
||||||
);
|
|
||||||
this.domElement.ownerDocument.removeEventListener(
|
|
||||||
'wheel',
|
|
||||||
this.boundOnScroll
|
|
||||||
);
|
|
||||||
window.removeEventListener('keydown', this.boundOnKeyDown);
|
window.removeEventListener('keydown', this.boundOnKeyDown);
|
||||||
window.removeEventListener('keyup', this.boundOnKeyUp);
|
window.removeEventListener('keyup', this.boundOnKeyUp);
|
||||||
}
|
}
|
||||||
@ -121,6 +102,7 @@ class CameraControls extends EventDispatcher {
|
|||||||
moveForward(distance: number) {
|
moveForward(distance: number) {
|
||||||
const camera = this.camera;
|
const camera = this.camera;
|
||||||
|
|
||||||
|
this.rotation.setFromQuaternion(camera.quaternion);
|
||||||
this.look.setFromMatrixColumn(camera.matrix, 0);
|
this.look.setFromMatrixColumn(camera.matrix, 0);
|
||||||
|
|
||||||
this.look.crossVectors(
|
this.look.crossVectors(
|
||||||
@ -134,6 +116,7 @@ class CameraControls extends EventDispatcher {
|
|||||||
moveUp(distance: number) {
|
moveUp(distance: number) {
|
||||||
const camera = this.camera;
|
const camera = this.camera;
|
||||||
|
|
||||||
|
this.rotation.setFromQuaternion(camera.quaternion);
|
||||||
this.look.copy(camera.up.clone().applyEuler(this.rotation));
|
this.look.copy(camera.up.clone().applyEuler(this.rotation));
|
||||||
|
|
||||||
camera.position.addScaledVector(this.look, distance);
|
camera.position.addScaledVector(this.look, distance);
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
GameRunner,
|
GameRunner,
|
||||||
Renderer,
|
Renderer,
|
||||||
LevelComponent,
|
LevelComponent,
|
||||||
|
WorldFile,
|
||||||
} from '@freeblox/engine';
|
} from '@freeblox/engine';
|
||||||
import { EditorEvents } from '../types/events';
|
import { EditorEvents } from '../types/events';
|
||||||
import { WorkspaceComponent } from './workspace';
|
import { WorkspaceComponent } from './workspace';
|
||||||
@ -102,6 +103,14 @@ export class Editor extends GameRunner {
|
|||||||
return this.level.serializeLevelSave(name);
|
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.
|
* Get selected objects.
|
||||||
*/
|
*/
|
||||||
|
@ -55,8 +55,7 @@ export class HistoryComponent extends EngineComponent {
|
|||||||
|
|
||||||
if (object[prop]?.clone) prevState[prop] = object[prop].clone();
|
if (object[prop]?.clone) prevState[prop] = object[prop].clone();
|
||||||
if (prop === 'parent') {
|
if (prop === 'parent') {
|
||||||
object?.removeFromParent();
|
(value as Object3D)?.attach(object);
|
||||||
(value as Object3D)?.add(object);
|
|
||||||
} else if (object[prop].copy && prop !== 'material') {
|
} else if (object[prop].copy && prop !== 'material') {
|
||||||
object[prop].copy(value);
|
object[prop].copy(value);
|
||||||
} else {
|
} else {
|
||||||
|
@ -102,20 +102,6 @@ export class WorkspaceComponent extends EngineComponent {
|
|||||||
this.background.name = '_background';
|
this.background.name = '_background';
|
||||||
this.helpers.name = '_helper';
|
this.helpers.name = '_helper';
|
||||||
scene.add(this.background, this.world, this.helpers);
|
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) {
|
private removeFromScene(scene: Object3D) {
|
||||||
|
@ -5,4 +5,5 @@ export interface MenuItem {
|
|||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
component?: any;
|
component?: any;
|
||||||
children?: MenuItem[];
|
children?: MenuItem[];
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
19
packages/editor/src/utils/export-file.ts
Normal file
19
packages/editor/src/utils/export-file.ts
Normal 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);
|
||||||
|
};
|
45
packages/editor/src/utils/read-file.ts
Normal file
45
packages/editor/src/utils/read-file.ts
Normal 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);
|
||||||
|
});
|
||||||
|
};
|
@ -13,8 +13,8 @@ import { assetManager } from '../assets/manager';
|
|||||||
import { WorldFile } from '../types/world-file';
|
import { WorldFile } from '../types/world-file';
|
||||||
import { World } from '../gameobjects/world.object';
|
import { World } from '../gameobjects/world.object';
|
||||||
import { instancableGameObjects } from '../gameobjects';
|
import { instancableGameObjects } from '../gameobjects';
|
||||||
import { Object3D } from 'three';
|
import { Color, Matrix4, Object3D, Vector3 } from 'three';
|
||||||
import { GameObject } from '../types/game-object';
|
import { GameObject, SerializedObject } from '../types/game-object';
|
||||||
import { environmentDefaults } from '../defaults/environment';
|
import { environmentDefaults } from '../defaults/environment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,6 +63,18 @@ export class LevelComponent extends EngineComponent {
|
|||||||
const newObject = new ObjectType();
|
const newObject = new ObjectType();
|
||||||
parent.add(newObject);
|
parent.add(newObject);
|
||||||
this.events.emit('sceneJoin', 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 {
|
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() {
|
private bindEvents() {
|
||||||
const changeEvent = (event: ChangeEvent) => {
|
const changeEvent = (event: ChangeEvent) => {
|
||||||
if (event.applied || !event.object) return;
|
if (event.applied || !event.object) return;
|
||||||
@ -101,14 +157,24 @@ export class LevelComponent extends EngineComponent {
|
|||||||
if (event.applied || !event.object || !event.parent) return;
|
if (event.applied || !event.object || !event.parent) return;
|
||||||
if (Array.isArray(event.object)) {
|
if (Array.isArray(event.object)) {
|
||||||
event.object.forEach((object) => {
|
event.object.forEach((object) => {
|
||||||
object.removeFromParent();
|
event.parent.attach(object);
|
||||||
event.parent.add(object);
|
this.events.emit('change', {
|
||||||
|
object: object,
|
||||||
|
property: 'parent',
|
||||||
|
value: event.parent,
|
||||||
|
applied: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.object.removeFromParent();
|
event.parent.attach(event.object);
|
||||||
event.parent.add(event.object);
|
this.events.emit('change', {
|
||||||
|
object: event.object,
|
||||||
|
property: 'parent',
|
||||||
|
value: event.parent,
|
||||||
|
applied: true,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const instanceEvent = (event: InstanceEvent) =>
|
const instanceEvent = (event: InstanceEvent) =>
|
||||||
|
6
packages/engine/src/gameobjects/group.object.ts
Normal file
6
packages/engine/src/gameobjects/group.object.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { GameObject3D } from '../types/game-object';
|
||||||
|
|
||||||
|
export class Group extends GameObject3D {
|
||||||
|
public objectType = Group.name;
|
||||||
|
public name = Group.name;
|
||||||
|
}
|
@ -6,8 +6,10 @@ import { WedgeCorner } from './wedge-corner.object';
|
|||||||
import { WedgeInnerCorner } from './wedge-inner-corner.object';
|
import { WedgeInnerCorner } from './wedge-inner-corner.object';
|
||||||
import { GameObject } from '../types/game-object';
|
import { GameObject } from '../types/game-object';
|
||||||
import { Instancable } from '../types/instancable';
|
import { Instancable } from '../types/instancable';
|
||||||
|
import { Group } from './group.object';
|
||||||
|
|
||||||
export const instancableGameObjects: Record<string, Instancable<GameObject>> = {
|
export const instancableGameObjects: Record<string, Instancable<GameObject>> = {
|
||||||
|
[Group.name]: Group,
|
||||||
[Brick.name]: Brick,
|
[Brick.name]: Brick,
|
||||||
[Cylinder.name]: Cylinder,
|
[Cylinder.name]: Cylinder,
|
||||||
[Sphere.name]: Sphere,
|
[Sphere.name]: Sphere,
|
||||||
@ -18,4 +20,4 @@ export const instancableGameObjects: Record<string, Instancable<GameObject>> = {
|
|||||||
|
|
||||||
export * from './environment.object';
|
export * from './environment.object';
|
||||||
export * from './world.object';
|
export * from './world.object';
|
||||||
export { Cylinder, Brick, Sphere, Wedge, WedgeCorner, WedgeInnerCorner };
|
export { Group, Cylinder, Brick, Sphere, Wedge, WedgeCorner, WedgeInnerCorner };
|
||||||
|
@ -42,13 +42,17 @@ export class GameObject extends Object3D {
|
|||||||
|
|
||||||
Object.assign(
|
Object.assign(
|
||||||
object,
|
object,
|
||||||
keys.reduce<{ [x: string]: unknown }>(
|
keys.reduce<{ [x: string]: unknown }>((obj, key) => {
|
||||||
(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,
|
...obj,
|
||||||
[key]: (this as Record<string, unknown>)[key],
|
[key]: value,
|
||||||
}),
|
};
|
||||||
{}
|
}, {})
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return object;
|
return object;
|
||||||
|
Loading…
Reference in New Issue
Block a user