working on texture picker

This commit is contained in:
Evert Prants 2023-06-14 20:54:49 +03:00
parent 759e67ddc6
commit 4c44c7923f
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
17 changed files with 406 additions and 28 deletions

View File

@ -14,13 +14,14 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Editor } from '../editor';
import { Editor, SelectionEvent } from '../editor';
import Menu from './menu/Menu.vue';
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 selection = ref(false);
const currentlyOpen = ref<string | undefined>(undefined);
const props = defineProps<{
@ -31,7 +32,7 @@ const emit = defineEmits<{
(e: 'update'): void;
}>();
// const { register } = useEditorEvents(props.editor);
const { register } = useEditorEvents(props.editor);
const fileMenu = [
{
@ -68,12 +69,14 @@ const editMenu = computed(() => [
id: 'cut',
label: 'Cut',
shortcut: 'CTRL+X',
disabled: !selection.value,
onClick: () => props.editor.events.emit('cut'),
},
{
id: 'copy',
label: 'Copy',
shortcut: 'CTRL+C',
disabled: !selection.value,
onClick: () => props.editor.events.emit('copy'),
},
{
@ -86,12 +89,14 @@ const editMenu = computed(() => [
id: 'duplicate',
label: 'Duplicate',
shortcut: 'CTRL+D',
disabled: !selection.value,
onClick: () => props.editor.events.emit('duplicate'),
},
{
id: 'delete',
label: 'Delete',
shortcut: 'Delete',
disabled: !selection.value,
onClick: () => props.editor.events.emit('delete'),
},
]);
@ -138,6 +143,13 @@ const loadLevelFromFile = async () => {
const obj = JSON.parse(data) as WorldFile;
props.editor.load(obj);
};
const setSelectionPreset = (event: SelectionEvent) => {
selection.value = !!event.selection?.length;
};
register('selected', setSelectionPreset);
register('deselected', setSelectionPreset);
</script>
<style lang="scss">

View File

@ -1,6 +1,6 @@
<template>
<div class="editor">
<Toolbar :editor="editorRef" />
<EditorToolbar :editor="editorRef" />
<TransformControls :editor="editorRef" />
<div class="editor-row">
<div class="viewport">
@ -8,15 +8,17 @@
</div>
<EditorSidebar :editor="editorRef" />
</div>
<EditorAssets :editor="editorRef" />
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { Editor } from '../editor';
import Toolbar from './Toolbar.vue';
import EditorToolbar from './EditorToolbar.vue';
import TransformControls from './TransformControls.vue';
import EditorSidebar from './EditorSidebar.vue';
import EditorAssets from './assets/EditorAssets.vue';
const wrapperRef = ref();
const editorRef = shallowRef<Editor>(new Editor());

View File

@ -0,0 +1,159 @@
<template>
<div class="assets">
<ul class="toolbar">
<Menu
v-for="item of tabs"
:id="item.id"
:open="activeTab === item.id"
@menuClick="clickTab(item.id)"
>{{ item.label }}</Menu
>
</ul>
<div class="assets-content">
<div class="assets-content-inner">
<template v-for="asset of assets">
<button type="button" class="asset-btn asset-btn-texture">
<div class="preview-wrapper">
<img :src="asset.data" class="preview" />
</div>
<span>{{ asset.name }}</span>
</button>
</template>
<button
type="button"
class="asset-btn asset-btn-add"
@click="uploadTexture"
>
<FileUploadSvg />
<span>New Texture</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, shallowRef } from 'vue';
import { useEditorEvents } from '../../composables/use-editor-events';
import { Editor } from '../../editor';
import FileUploadSvg from '../../icons/file-upload.svg.vue';
import Menu from '../menu/Menu.vue';
import { readFileToString } from '../../utils/read-file';
import { Asset, assetManager } from '@freeblox/engine';
const props = defineProps<{
editor: Editor;
}>();
const { register } = useEditorEvents(props.editor);
const emit = defineEmits<{
(e: 'update'): void;
}>();
const assets = shallowRef<Asset[]>(assetManager.assets);
const activeTab = ref('textures');
const tabs = computed(() => [
{
id: 'textures',
label: 'Textures',
},
]);
const clickTab = (tabId: string) => {
activeTab.value = tabId;
};
const updateRef = async () => {
assets.value = [];
await nextTick();
assets.value = assetManager.assets;
};
const uploadTexture = async () => {
const read = await readFileToString('image/*', true);
const asset = await assetManager.createAsset(read, 'Texture', 'Texture');
updateRef();
};
register('loadComplete', () => updateRef());
</script>
<style lang="scss">
.assets {
--asset-width: 376px;
--asset-height: 140px;
--asset-toolbar-height: 36px;
--asset-content-height: calc(
var(--asset-height) - var(--asset-toolbar-height)
);
position: absolute;
left: 0;
bottom: 0;
width: calc(70% - var(--asset-width));
margin-left: 20px;
height: var(--asset-height);
background-color: #f7f7f7;
overflow: hidden;
&-toolbar {
display: flex;
flex-direction: row;
height: var(--asset-toolbar-height);
background-color: #efefef;
}
&-content {
overflow: auto;
height: var(--asset-content-height);
width: 100%;
&-inner {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
}
}
.asset-btn {
display: flex;
flex-direction: column;
align-items: center;
appearance: none;
background-color: transparent;
border: 0;
height: calc(var(--asset-content-height) - 8px);
width: calc(var(--asset-content-height) - 8px);
padding: 4px;
cursor: pointer;
margin: 4px 0;
&-add {
border: 4px dashed #ddd;
border-radius: 8px;
}
&-texture {
padding: 4px;
}
.preview-wrapper {
position: relative;
overflow: hidden;
flex-grow: 1;
width: 100%;
img {
object-fit: contain;
height: 100%;
width: 100%;
}
}
svg {
fill: #ddd;
}
}
</style>

View File

@ -0,0 +1,92 @@
<template>
<div class="form-field-asset">
<label class="form-field-asset-label" :for="id">{{ label }}</label>
<div class="form-field-asset-input">
<Menu
:id="id"
class="form-field-asset-button"
:items="pickable"
overflowing
position="top-right"
>Assign Texture</Menu
>
</div>
</div>
</template>
<script setup lang="ts">
import { assetManager } from '@freeblox/engine';
import { computed, ref } from 'vue';
import AssetPickerAsset from './AssetPickerAsset.vue';
import Menu from '../menu/Menu.vue';
import { MenuItem } from '../../types/menu.interface';
const props = defineProps<{
name: string;
label: string;
value?: string;
}>();
const modelValueId = ref(props.value);
const emit = defineEmits<{
(e: 'update', value?: string): void;
}>();
const id = computed(() => `form-${props.name}`);
const pickable = computed<MenuItem[]>(() => [
{
id: 'texture-none',
label: 'None',
onClick: () => {
modelValueId.value = undefined;
emit('update', undefined);
},
},
...assetManager.assets.map((item) => ({
id: item.path || item.name,
label: item.name,
asset: item,
component: AssetPickerAsset,
onClick: () => {
modelValueId.value = item.path;
emit('update', item.path);
},
})),
]);
</script>
<style lang="scss">
.form-field-asset {
display: grid;
grid-template-columns: 1fr 1fr;
&-label {
padding: 8px;
text-transform: capitalize;
user-select: none;
}
&-input {
position: relative;
display: flex;
margin: 2px 0;
}
&-button {
width: 100%;
> .menu-button {
appearance: none;
cursor: pointer;
border: 0;
background-color: #ffffff;
padding: 8px;
width: 100%;
margin: 2px 0;
font-size: 0.75rem;
border-radius: 4px;
}
}
}
</style>

View File

@ -0,0 +1,29 @@
<template>
<div class="asset-picker-asset">
<img :src="asset.data" />
{{ asset.name }}
</div>
</template>
<script setup lang="ts">
import { Asset } from '@freeblox/engine';
defineProps<{
asset: Asset;
}>();
</script>
<style lang="scss">
.asset-picker-asset {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
img {
width: 24px;
height: 24px;
object-fit: contain;
}
}
</style>

View File

@ -58,8 +58,13 @@ const props = withDefaults(
defineProps<{
id: string;
items?: MenuItem[];
onClick?: () => void;
position?: 'bottom' | 'right' | 'left';
position?:
| 'bottom'
| 'bottom-right'
| 'top'
| 'top-right'
| 'right'
| 'left';
trigger?: 'click' | 'hover' | 'none';
top?: number;
left?: number;
@ -67,22 +72,26 @@ const props = withDefaults(
open?: boolean;
disabled?: boolean;
submenu?: boolean;
overflowing?: boolean;
}>(),
{
position: 'bottom',
trigger: 'click',
open: false,
overflowing: false,
items: () => [],
}
);
const emit = defineEmits<{
(e: 'toggle', v: boolean, t: ToggleTrigger): void;
(e: 'menuClick'): void;
}>();
const buttonRef = ref();
const isOpen = ref(props.open);
const toggle = (trigger: ToggleTrigger = 'user') => {
if (!props.items?.length) return emit('menuClick');
isOpen.value = !isOpen.value;
emit('toggle', isOpen.value, trigger);
};
@ -92,6 +101,7 @@ const menuClass = computed(() => [
isOpen.value ? 'menu-wrapper--open' : 'menu-wrapper--closed',
`menu-wrapper--${props.position}`,
props.submenu ? 'menu-wrapper--submenu' : '',
props.overflowing ? 'menu-wrapper--overflowing' : '',
]);
const menuStyle = computed(() =>
@ -155,6 +165,20 @@ watch(
top: 100%;
}
&--top > .menu-dropdown {
bottom: 100%;
}
&--bottom-right > .menu-dropdown {
top: 100%;
right: 0;
}
&--top-right > .menu-dropdown {
bottom: 100%;
right: 0;
}
&--right > .menu-dropdown {
top: 0;
left: 100%;
@ -184,6 +208,11 @@ watch(
& > .menu-button {
height: 100%;
}
&--overflowing > .menu-dropdown {
overflow: auto;
max-height: 300px;
}
}
&-dropdown {
@ -211,6 +240,7 @@ watch(
&-button {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
appearance: none;
font-size: 1rem;

View File

@ -14,7 +14,7 @@
</template>
<script setup lang="ts">
import { GameObject } from '@freeblox/engine';
import { AssetInfo, GameObject } from '@freeblox/engine';
import type { Component } from 'vue';
import { computed } from 'vue';
import Field from '../form/Field.vue';
@ -22,6 +22,7 @@ import { Color, Euler, Vector3 } from 'three';
import Vector3Field from '../form/Vector3Field.vue';
import Checkbox from '../form/Checkbox.vue';
import ColorPicker from '../form/ColorPicker.vue';
import AssetPicker from '../form/AssetPicker.vue';
interface FormItem {
name: string;
@ -114,6 +115,17 @@ const formFields = computed(() => {
component: Checkbox,
});
}
if (property.definition.type === AssetInfo) {
fields.push({
name: property.definition.name!,
label: toCapitalizedWords(property.definition.name!),
value: (object as unknown as Record<string, unknown>)[
property.definition.name!
],
component: AssetPicker,
});
}
});
return fields;

View File

@ -21,7 +21,7 @@ class CameraControls extends EventDispatcher {
public pointerSpeed = 1.5;
public panSpeed = 1;
public movementSpeed = 0.025;
public shiftMultiplier = 1.2;
public shiftMultiplier = 2;
public zoomScale = 100;
public screenSpacePanning = true;
@ -42,6 +42,7 @@ class CameraControls extends EventDispatcher {
right: 0,
up: 0,
down: 0,
multiplier: 1,
};
constructor(private camera: Object3D, private domElement: HTMLElement) {
@ -76,27 +77,27 @@ class CameraControls extends EventDispatcher {
update(dt: number) {
if (this.movement.forward !== 0) {
this.moveForward(this.movement.forward * dt);
this.moveForward(this.movement.forward * dt * this.movement.multiplier);
}
if (this.movement.backward !== 0) {
this.moveForward(-this.movement.backward * dt);
this.moveForward(-this.movement.backward * dt * this.movement.multiplier);
}
if (this.movement.right !== 0) {
this.moveRight(this.movement.right * dt);
this.moveRight(this.movement.right * dt * this.movement.multiplier);
}
if (this.movement.left !== 0) {
this.moveRight(-this.movement.left * dt);
this.moveRight(-this.movement.left * dt * this.movement.multiplier);
}
if (this.movement.up !== 0) {
this.moveUp(this.movement.up * dt);
this.moveUp(this.movement.up * dt * this.movement.multiplier);
}
if (this.movement.down !== 0) {
this.moveUp(-this.movement.down * dt);
this.moveUp(-this.movement.down * dt * this.movement.multiplier);
}
}
@ -212,6 +213,9 @@ class CameraControls extends EventDispatcher {
if (event.altKey || event.ctrlKey) return;
switch (event.code) {
case 'ShiftLeft':
this.movement.multiplier = this.shiftMultiplier;
break;
case 'KeyE':
this.movement.up = this.movementSpeed;
break;
@ -240,6 +244,9 @@ class CameraControls extends EventDispatcher {
if (event.altKey || event.ctrlKey) return;
switch (event.code) {
case 'ShiftLeft':
this.movement.multiplier = 1;
break;
case 'KeyE':
this.movement.up = 0;
break;

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960">
<path
d="M452-202h60v-201l82 82 42-42-156-152-154 154 42 42 84-84v201ZM220-80q-24 0-42-18t-18-42v-680q0-24 18-42t42-18h361l219 219v521q0 24-18 42t-42 18H220Zm331-554v-186H220v680h520v-494H551ZM220-820v186-186 680-680Z"
/>
</svg>
</template>

View File

@ -1,4 +1,5 @@
export interface MenuItem {
[x: string]: unknown;
id: string;
label?: string;
shortcut?: string;

View File

@ -1,5 +1,3 @@
import { h } from 'vue';
export const readString = (file: File, dataUrl = false) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@ -16,7 +14,7 @@ export const readString = (file: File, dataUrl = false) => {
});
};
export const readFileToString = (allowedTypes?: string) => {
export const readFileToString = (allowedTypes?: string, dataUrl = false) => {
let cleanUpTimer: ReturnType<typeof setTimeout>;
const input = document.createElement('input');
input.type = 'file';
@ -31,7 +29,7 @@ export const readFileToString = (allowedTypes?: string) => {
return new Promise<string>((resolve, reject) => {
input.addEventListener('change', () => {
input.files && readString(input.files![0]).then(resolve, reject);
input.files && readString(input.files![0], dataUrl).then(resolve, reject);
cleanUp();
});

View File

@ -1,11 +1,19 @@
import { CubeTexture, CubeTextureLoader, Texture, TextureLoader } from 'three';
import { Asset } from '../types/asset';
import { Asset, AssetsEvents } from '../types/asset';
import { EventEmitter } from '../utils/events';
export class AssetManagerFactory {
export class AssetManagerFactory extends EventEmitter<AssetsEvents> {
public assets: Asset[] = [];
public texureLoader = new TextureLoader();
public cubeTextureLoader = new CubeTextureLoader();
constructor() {
super();
this.on('load', (input) =>
Array.isArray(input) ? this.loadAll(input) : this.load(input)
);
}
/**
* Get an asset by path, this is an unique identifier
* @param path Asset path
@ -44,6 +52,7 @@ export class AssetManagerFactory {
* @param asset Remote or local asset data
*/
async load(asset: Asset) {
this.emit('loadStart', asset);
return (
asset.type === 'Texture'
? this.loadTextureData(
@ -54,10 +63,16 @@ export class AssetManagerFactory {
asset.remote ? asset.path : asset.data,
asset.name
)
).then((texture) => {
asset.texture = texture;
return asset;
});
)
.then((texture) => {
asset.texture = texture;
this.emit('loadComplete', asset);
return asset;
})
.catch((error) => {
this.emit('loadError', error);
throw error;
});
}
/**

View File

@ -103,6 +103,7 @@ export class LevelComponent extends EngineComponent {
// Load world
this.deserializeObject(save.world);
this.events.emit('loadComplete');
}
private recursiveCreate(entry: SerializedObject, setParent?: Object3D) {

View File

@ -43,6 +43,7 @@ export class Brick extends GameObject3D {
if (!path) {
this.material.map = null;
this.texturePath = undefined;
this.material.needsUpdate = true;
return;
}
@ -54,6 +55,7 @@ export class Brick extends GameObject3D {
this.texturePath = path;
this.material.map = asset.texture;
this.material.needsUpdate = true;
}
@EditorProperty({ type: Boolean })

View File

@ -12,3 +12,10 @@ export interface Asset {
export class AssetInfo {
constructor(public path: string, public name: string) {}
}
export type AssetsEvents = {
load(asset: Asset | Asset[]): void;
loadStart(asset: Asset): void;
loadComplete(asset: Asset): void;
loadError(asset: Asset, error?: Error): void;
};

View File

@ -79,6 +79,7 @@ export type EngineEvents = {
instance: (event: InstanceEvent) => void;
sceneJoin: (event: Object3D) => void;
sceneLeave: (event: Object3D) => void;
loadComplete: () => void;
initialized: () => void;
reset: () => void;
};

View File

@ -9,6 +9,7 @@ export class GameObject extends Object3D {
@EditorProperty({ type: String })
public override name: string = '';
@EditorProperty({ type: Boolean })
public override visible: boolean = true;
@ -21,7 +22,9 @@ export class GameObject extends Object3D {
return readMetadataOf<string>(this, 'excludedProperties');
}
/** The exposed properties for this game object, used for the editor */
/**
* The exposed properties for this game object, used for the editor
*/
get properties() {
const exclude = this.excludedProperties;
const properties = readMetadataOf<Property>(this, 'properties');
@ -85,9 +88,9 @@ export class GameObject extends Object3D {
const indexable = this as any;
if (indexable[key]?.fromArray && Array.isArray(input[key])) {
indexable[key].fromArray(input[key]);
} else if (indexable[key].isColor) {
} else if (indexable[key]?.isColor) {
indexable[key] = new Color(input[key] as string);
} else if (indexable[key].copy) {
} else if (indexable[key]?.copy) {
indexable[key].copy(input[key]);
} else {
indexable[key] = input[key];