This commit is contained in:
Evert Prants 2023-06-05 18:29:37 +03:00
parent 9ea5b6910b
commit 2f68699913
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
20 changed files with 383 additions and 26 deletions

View File

@ -79,12 +79,18 @@ const rerenderSelection = () => {
};
register('initialized', () => createSceneMap());
register('sceneJoin', () => createSceneMap());
register('sceneLeave', () => createSceneMap());
register('selected', (event) => updateSelectionMap(event));
register('deselected', (event) => updateSelectionMap(event));
register('change', (event) => {
if (['name', 'visible', 'parent'].includes(event.property)) createSceneMap();
rerenderSelection();
});
register('reset', () => {
createSceneMap();
rerenderSelection();
});
onMounted(() => createSceneMap());
</script>

View File

@ -1,9 +1,36 @@
<template>
<div class="toolbar"></div>
<div class="toolbar">
<Menu
id="file"
:items="fileMenu"
:open-sibling="currentlyOpen"
@toggle="(state, reason) => toggleEvent('file', state, reason)"
>File</Menu
>
<Menu
id="edit"
:items="editMenu"
:open-sibling="currentlyOpen"
@toggle="(state, reason) => toggleEvent('edit', state, reason)"
>Edit</Menu
>
<Menu
id="add"
:items="addMenu"
:open-sibling="currentlyOpen"
@toggle="(state, reason) => toggleEvent('add', state, reason)"
>Add</Menu
>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Editor } from '../editor';
import Menu from './menu/Menu.vue';
import { instancableGameObjects } from '@freeblox/engine';
const currentlyOpen = ref<string | undefined>(undefined);
const props = defineProps<{
editor: Editor;
@ -12,6 +39,71 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'update'): void;
}>();
const fileMenu = [
{
id: 'new',
label: 'New',
onClick: () => props.editor.events.emit('reset'),
},
{
id: 'export',
label: 'Export',
onClick: () => {},
},
];
const editMenu = computed(() => [
{
id: 'undo',
label: 'Undo',
shortcut: 'CTRL+Z',
onClick: () => props.editor.events.emit('undo'),
},
{
id: 'redo',
label: 'Redo',
shortcut: 'CTRL+Y',
onClick: () => props.editor.events.emit('redo'),
},
{
id: 'cut',
label: 'Cut',
shortcut: 'CTRL+X',
onClick: () => props.editor.events.emit('cut'),
},
{
id: 'copy',
label: 'Copy',
shortcut: 'CTRL+C',
onClick: () => props.editor.events.emit('copy'),
},
{
id: 'paste',
label: 'Paste',
shortcut: 'CTRL+V',
onClick: () => props.editor.events.emit('paste', undefined),
},
{
id: 'delete',
label: 'Delete',
shortcut: 'Delete',
onClick: () => props.editor.events.emit('delete'),
},
]);
const addMenu = computed(() =>
Object.keys(instancableGameObjects).map((item) => ({
id: item,
label: item,
onClick: () => props.editor.events.emit('instance', { type: item }),
}))
);
const toggleEvent = (id: string, state: boolean, reason: 'user' | 'leave') => {
if (state) currentlyOpen.value = id;
else if (reason === 'user') currentlyOpen.value = undefined;
};
</script>
<style lang="scss">

View File

@ -1,6 +1,6 @@
<template>
<div class="form-field-check">
<label class="form-field-check-label" :for="id">{{ name }}</label>
<label class="form-field-check-label" :for="id">{{ label }}</label>
<div class="form-field-check-wrap">
<input
class="form-field-check-input"
@ -18,6 +18,7 @@ import { computed, ref, watch } from 'vue';
const props = defineProps<{
name: string;
label: string;
value: boolean;
}>();
@ -41,11 +42,11 @@ watch(modelValue, (value, oldValue) => {
&-wrap {
padding: 8px;
margin: 2px 0;
}
&-input {
border: 0;
background-color: #efefef;
}
&-label {

View File

@ -1,6 +1,6 @@
<template>
<div class="form-field-color">
<label class="form-field-color-label" :for="id">{{ name }}</label>
<label class="form-field-color-label" :for="id">{{ label }}</label>
<div class="form-field-color-input">
<div class="form-field-color-wrapper">
<input
@ -34,6 +34,7 @@ const secretInput = ref();
const props = defineProps<{
name: string;
label: string;
value: Color;
}>();
@ -66,6 +67,7 @@ const changed = ($event: Event) => {
&-input {
position: relative;
display: flex;
margin: 2px 0;
}
&-wrapper {
@ -77,6 +79,7 @@ const changed = ($event: Event) => {
&-button {
display: flex;
justify-content: flex-start;
font-family: monospace;
align-items: center;
appearance: none;
padding: 4px;
@ -84,6 +87,8 @@ const changed = ($event: Event) => {
border: 0;
width: 100%;
text-align: left;
background: transparent;
font-size: 0.75rem;
}
&-preview {

View File

@ -1,6 +1,6 @@
<template>
<div class="form-field">
<label class="form-field-label" :for="id">{{ name }}</label>
<label class="form-field-label" :for="id">{{ label }}</label>
<input
class="form-field-input"
type="text"
@ -17,6 +17,7 @@ import { computed, ref, watch } from 'vue';
const props = defineProps<{
name: string;
label: string;
value: unknown;
type?: 'string' | 'number';
}>();
@ -46,9 +47,12 @@ const changed = () => {
&-input {
border: 0;
background-color: #efefef;
background-color: #ffffff;
padding: 8px;
width: 100%;
margin: 2px 0;
font-size: 0.75rem;
border-radius: 4px;
}
&-label {

View File

@ -1,11 +1,11 @@
<template>
<div class="form-field-vec">
<label class="form-field-vec-label" :for="id + '-x'">{{ name }}</label>
<label class="form-field-vec-label" :for="id + '-x'">{{ label }}</label>
<div class="form-field-vec-pieces">
<div class="form-field-vec-piece">
<label class="form-field-vec-label-sub" :for="id + '-x'">{{
names[0]
}}</label>
<label class="form-field-vec-label-sub" :for="id + '-x'"
>{{ names[0] }}:</label
>
<input
class="form-field-vec-input"
type="text"
@ -16,9 +16,9 @@
/>
</div>
<div class="form-field-vec-piece">
<label class="form-field-vec-label-sub" :for="id + '-y'">{{
names[1]
}}</label>
<label class="form-field-vec-label-sub" :for="id + '-y'"
>{{ names[1] }}:</label
>
<input
class="form-field-vec-input"
type="text"
@ -29,9 +29,9 @@
/>
</div>
<div class="form-field-vec-piece">
<label class="form-field-vec-label-sub" :for="id + '-z'">{{
names[2]
}}</label>
<label class="form-field-vec-label-sub" :for="id + '-z'"
>{{ names[2] }}:</label
>
<input
class="form-field-vec-input"
type="text"
@ -51,6 +51,7 @@ import { computed, ref } from 'vue';
const props = defineProps<{
name: string;
label: string;
value: Vector3 | Color | Euler;
type?: 'vector' | 'color' | 'euler';
}>();
@ -132,12 +133,16 @@ const changed = () => {
padding: 8px 4px;
width: 16px;
user-select: none;
font-size: 0.875rem;
}
&-pieces {
display: grid;
position: relative;
grid-template-columns: repeat(3, 1fr);
background-color: #ffffff;
margin: 2px 0;
border-radius: 4px;
}
&-piece {
@ -150,8 +155,9 @@ const changed = () => {
display: block;
width: 32px;
border: 0;
background-color: #efefef;
background-color: #ffffff;
text-align: center;
font-size: 0.75rem;
}
}
</style>

View File

@ -0,0 +1,166 @@
<template>
<div :class="menuClass">
<button
type="button"
class="menu-button"
@click="toggle()"
ref="buttonRef"
@mouseenter="mouseEnter"
>
<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">
<template v-if="item.component">
<component :is="item.component" v-bind="item"></component>
</template>
<template v-else>
<span class="menu-button-inner">{{ item.label }}</span>
<span class="menu-button-shortcut" v-if="item.shortcut">{{
item.shortcut
}}</span>
</template>
</button>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { ref } from 'vue';
type ToggleTrigger = 'user' | 'leave';
const buttonRef = ref();
const isOpen = ref(false);
export interface MenuItem {
id: string;
label?: string;
shortcut?: string;
onClick?: () => void;
component?: any;
children?: MenuItem[];
}
const props = withDefaults(
defineProps<{
id: string;
items?: MenuItem[];
onClick?: () => void;
position?: 'bottom' | 'right';
trigger?: 'click' | 'hover';
openSibling?: string;
}>(),
{
position: 'bottom',
trigger: 'click',
items: () => [],
}
);
const emit = defineEmits<{
(e: 'toggle', v: boolean, t: ToggleTrigger): void;
}>();
const toggle = (trigger: ToggleTrigger = 'user') => {
isOpen.value = !isOpen.value;
emit('toggle', isOpen.value, trigger);
};
const menuClass = computed(() => [
'menu-wrapper',
isOpen.value ? 'menu-wrapper--open' : 'menu-wrapper--closed',
`menu-wrapper--${props.position}`,
]);
const mouseEnter = () => {
if (
isOpen.value ||
((!props.openSibling || props.openSibling === props.id) &&
props.trigger !== 'hover')
)
return;
toggle();
};
onMounted(() => {
const handler = (ev: MouseEvent) => {
if ((ev.target as HTMLElement).isEqualNode(buttonRef.value)) return;
if (isOpen.value) toggle();
};
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
});
watch(
() => props.openSibling,
(value) => {
if (value === props.id || !isOpen.value) return;
toggle('leave');
}
);
</script>
<style lang="scss">
.menu {
&-wrapper {
position: relative;
&--bottom > .menu-dropdown {
top: 100%;
}
&--right > .menu-dropdown {
top: 0;
left: 100%;
}
&--open > .menu-button {
background-color: #e6e6e6;
}
& > .menu-button {
height: 100%;
}
}
&-dropdown {
background-color: #e6e6e6;
position: absolute;
z-index: 1001;
&-inner {
min-width: 180px;
& > .menu-button {
width: 100%;
text-align: left;
}
}
}
&-button {
display: flex;
justify-content: space-between;
cursor: pointer;
appearance: none;
font-size: 1rem;
padding: 8px 16px;
border: 0;
background: transparent;
&:hover {
background-color: #f8f8f8;
}
&-shortcut {
font-size: 0.875rem;
color: #6b6b6b;
}
}
}
</style>

View File

@ -4,6 +4,7 @@
<component
:is="form.component"
:name="form.name"
:label="form.label"
:value="form.value"
:type="form.type"
@update="(value: string) => propertyChanged(form.name, value)"
@ -24,6 +25,7 @@ import ColorPicker from '../form/ColorPicker.vue';
interface FormItem {
name: string;
label: string;
value: unknown;
type?: unknown;
component: Component;
@ -42,6 +44,16 @@ const propertyChanged = (property: string, value: string) => {
emit('update', object, property, value);
};
function toCapitalizedWords(name: string) {
const words = name.match(/[A-Za-z][a-z]*/g) || [];
return words.map(capitalize).join(' ');
}
function capitalize(word: string) {
return word.charAt(0).toUpperCase() + word.substring(1);
}
const formFields = computed(() => {
// TODO: multi-edit
const object = props.selection[0];
@ -54,6 +66,7 @@ const formFields = computed(() => {
if (property.type === String || property.type === Number) {
fields.push({
name: property.name,
label: toCapitalizedWords(property.name),
value: (object as unknown as Record<string, unknown>)[property.name],
type: property.type === String ? 'string' : 'number',
component: Field,
@ -63,6 +76,7 @@ const formFields = computed(() => {
if (property.type === Vector3 || property.type === Euler) {
fields.push({
name: property.name,
label: toCapitalizedWords(property.name),
value: (object as unknown as Record<string, unknown>)[property.name],
type: property.type === Vector3 ? 'vector' : 'euler',
component: Vector3Field,
@ -72,6 +86,7 @@ const formFields = computed(() => {
if (property.type === Color) {
fields.push({
name: property.name,
label: toCapitalizedWords(property.name),
value: (object as unknown as Record<string, unknown>)[property.name],
component: ColorPicker,
});
@ -80,6 +95,7 @@ const formFields = computed(() => {
if (property.type === Boolean) {
fields.push({
name: property.name,
label: toCapitalizedWords(property.name),
value: (object as unknown as Record<string, unknown>)[property.name],
component: Checkbox,
});

View File

@ -10,6 +10,7 @@
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #f7f7f7;
&-title {
background-color: #e5e5e5;

View File

@ -53,7 +53,7 @@ const filtered = (items: Object3D[]) =>
flex-direction: column;
&-button {
background-color: #efefef;
background-color: transparent;
user-select: none;
appearance: none;
padding: 8px;
@ -62,7 +62,7 @@ const filtered = (items: Object3D[]) =>
cursor: pointer;
&.selected {
background-color: #ffffff;
background-color: #bcefff;
}
}
}

View File

@ -179,6 +179,11 @@ export class HistoryComponent extends EngineComponent {
const undo = this.undo.bind(this);
const redo = this.redo.bind(this);
const resetEvent = () => {
this.history.length = 0;
this.restory.length = 0;
};
this.events.addListener('change', changeEvent);
this.events.addListener('transformStart', transformStart);
this.events.addListener('transformEnd', transformEnd);
@ -186,6 +191,7 @@ export class HistoryComponent extends EngineComponent {
this.events.addListener('remove', removeEvent);
this.events.addListener('undo', undo);
this.events.addListener('redo', redo);
this.events.addListener('resetHistory', resetEvent);
return () => {
this.events.removeEventListener('change', changeEvent);
@ -195,6 +201,7 @@ export class HistoryComponent extends EngineComponent {
this.events.removeEventListener('remove', removeEvent);
this.events.removeEventListener('undo', undo);
this.events.removeEventListener('redo', redo);
this.events.removeEventListener('resetHistory', resetEvent);
};
}
}

View File

@ -12,6 +12,7 @@ import {
Wedge,
WedgeCorner,
WedgeInnerCorner,
World,
} from '@freeblox/engine';
import {
AxesHelper,
@ -31,7 +32,6 @@ import {
} from '../types/events';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import { World } from '@freeblox/engine/dist/gameobjects/world.object';
/**
* This component does most of the work related to editing the level.
@ -349,6 +349,25 @@ export class WorkspaceComponent extends EngineComponent {
if (this.cutOperation) this.cutOperation = false;
};
const resetEvent = () => {
const oldSelection = [...this.selection];
this.cutOperation = false;
this.transforming = false;
this.selection.length = 0;
this.clipboard.length = 0;
this.transformPosition = undefined;
this.transformRotation = undefined;
this.transformScale = undefined;
this.transforming = false;
oldSelection.forEach((selection) =>
this.events.emit('deselected', {
object: selection,
selection: [],
})
);
this.events.emit('resetHistory');
};
this.events.addListener('mouseDown', mouseDownEventHandler);
this.events.addListener('mouseMove', mouseMoveEventHandler);
this.events.addListener('mouseUp', mouseUpEventHandler);
@ -364,6 +383,7 @@ export class WorkspaceComponent extends EngineComponent {
this.events.addListener('paste', pasteEvent);
this.events.addListener('delete', deleteEvent);
this.events.addListener('undo', undoEvent);
this.events.addListener('reset', resetEvent);
return () => {
this.events.removeEventListener('mouseDown', mouseDownEventHandler);
@ -384,6 +404,7 @@ export class WorkspaceComponent extends EngineComponent {
this.events.removeEventListener('paste', pasteEvent);
this.events.removeEventListener('delete', deleteEvent);
this.events.removeEventListener('undo', undoEvent);
this.events.removeEventListener('reset', resetEvent);
};
}

View File

@ -44,6 +44,7 @@ export type Events = {
copy: () => void;
delete: () => void;
paste: (event: Object3D | undefined) => void;
resetHistory: () => void;
};
export type EditorEvents = Events & EngineEvents;

View File

@ -92,6 +92,13 @@ export class AssetManagerFactory {
}));
}
/**
* Free all assets from memory
*/
freeAll() {
this.assets.length = 0;
}
/**
* Load texture
* @param path Path

View File

@ -15,6 +15,7 @@ import { World } from '../gameobjects/world.object';
import { instancableGameObjects } from '../gameobjects';
import { Object3D } from 'three';
import { GameObject } from '../types/game-object';
import { environmentDefaults } from '../defaults/environment';
/**
* Game level management component
@ -113,16 +114,25 @@ export class LevelComponent extends EngineComponent {
const instanceEvent = (event: InstanceEvent) =>
this.createObject(event.type, event.parent);
const resetEvent = () => {
this.world.clear();
assetManager.freeAll();
this.events.emit('setEnvironment', environmentDefaults);
Object.assign(this.environment, environmentDefaults);
};
this.events.addListener('change', changeEvent);
this.events.addListener('remove', removeEvent);
this.events.addListener('reparent', reparentEvent);
this.events.addListener('instance', instanceEvent);
this.events.addListener('reset', resetEvent);
return () => {
this.events.removeEventListener('change', changeEvent);
this.events.removeEventListener('remove', removeEvent);
this.events.removeEventListener('reparent', reparentEvent);
this.events.removeEventListener('instance', instanceEvent);
this.events.removeEventListener('reset', resetEvent);
};
}
}

View File

@ -0,0 +1,10 @@
import { Color, Vector3 } from 'three';
export const environmentDefaults = {
sunColor: new Color(0xffffff),
sunPosition: new Vector3(1, 1, 1),
sunStrength: 1,
ambientColor: new Color(0x8a8a8a),
ambientStrength: 1,
clearColor: new Color(0x00aaff),
};

View File

@ -5,6 +5,7 @@ import {
SerializedObject,
} from '../types/game-object';
import { Property } from '../types/property';
import { environmentDefaults } from '../defaults/environment';
export const environmentEditorProperties: EditorProperties = {
sunColor: new Property('sunColor', Color, true, []),
@ -20,12 +21,12 @@ export class Environment extends GameObject {
public name = Environment.name;
public virtual = true;
sunColor = new Color(0xffffff);
sunPosition = new Vector3(1, 1, 1);
sunStrength = 1;
ambientColor = new Color(0x8a8a8a);
ambientStrength = 1;
clearColor = new Color(0x00aaff);
sunColor = environmentDefaults.sunColor.clone();
sunPosition = environmentDefaults.sunPosition.clone();
sunStrength = environmentDefaults.sunStrength;
ambientColor = environmentDefaults.ambientColor.clone();
ambientStrength = environmentDefaults.ambientStrength;
clearColor = environmentDefaults.clearColor.clone();
constructor() {
super(environmentEditorProperties);

View File

@ -17,4 +17,5 @@ export const instancableGameObjects: Record<string, Instancable<GameObject>> = {
};
export * from './environment.object';
export * from './world.object';
export { Cylinder, Brick, Sphere, Wedge, WedgeCorner, WedgeInnerCorner };

View File

@ -4,3 +4,4 @@ export * from './types';
export * from './components';
export * from './gameobjects';
export * from './assets';
export * from './defaults/environment';

View File

@ -79,4 +79,5 @@ export type EngineEvents = {
sceneJoin: (event: Object3D) => void;
sceneLeave: (event: Object3D) => void;
initialized: () => void;
reset: () => void;
};