controls tweaks, explorer context menu
This commit is contained in:
parent
c26292e441
commit
919cbba32f
@ -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());
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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);
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -5,4 +5,5 @@ export interface MenuItem {
|
||||
onClick?: () => void;
|
||||
component?: any;
|
||||
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 { 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) =>
|
||||
|
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 { 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 };
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user