This commit is contained in:
Evert Prants 2023-06-04 13:30:20 +03:00
parent 8e073effa8
commit b90e43bf79
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
37 changed files with 892 additions and 138 deletions

View File

@ -29,6 +29,7 @@
"devDependencies": {
"@types/three": "^0.152.1",
"@vitejs/plugin-vue": "^4.1.0",
"sass": "^1.62.1",
"tslib": "^2.5.3",
"typescript": "^5.0.2",
"vite": "^4.3.9",

View File

@ -0,0 +1,69 @@
<template>
<div class="sidebar">
<SidebarPanel>
<template #title>Explorer</template>
<SidebarRow
v-for="object of items"
:item="object"
:selectionMap="selectionMap"
:depth="0"
@select="selectItem"
/>
</SidebarPanel>
<SidebarPanel>
<template #title>Properties</template>
asd
</SidebarPanel>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref, shallowRef } from 'vue';
import { useEditorEvents } from '../composables/use-editor-events';
import { Editor, SelectionEvent } from '../editor';
import SidebarPanel from './sidebar/SidebarPanel.vue';
import { GameObject } from '@freeblox/engine';
import { Object3D } from 'three';
import SidebarRow from './sidebar/SidebarRow.vue';
const items = shallowRef<Object3D[]>([]);
const selectionMap = ref<string[]>([]);
const props = defineProps<{
editor: Editor;
}>();
const { register } = useEditorEvents(props.editor);
const createSceneMap = () => {
if (!props.editor?.running) return;
const sceneTree = props.editor.getSceneTree();
items.value = [sceneTree.world, sceneTree.environment];
};
const updateSelectionMap = (event: SelectionEvent) => {
selectionMap.value = event.selection.map((item) => item.uuid);
};
const selectItem = (item: Object3D, ctrl: boolean) => {
props.editor.events.emit('select', {
object: item as GameObject,
multi: ctrl,
});
};
register('initialized', () => createSceneMap());
register('selected', (event) => updateSelectionMap(event));
register('deselected', (event) => updateSelectionMap(event));
onMounted(() => createSceneMap());
</script>
<style lang="scss">
.sidebar {
display: grid;
grid-template-rows: 1fr 1fr;
width: 360px;
}
</style>

View File

@ -1,23 +1,56 @@
<template>
<div class="editor-wrapper" ref="wrapperRef"></div>
<div class="editor">
<Toolbar :editor="editorRef" />
<TransformControls :editor="editorRef" />
<div class="editor-row">
<div class="viewport">
<div class="canvas-wrapper" ref="wrapperRef"></div>
</div>
<EditorSidebar :editor="editorRef" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, shallowRef } from 'vue';
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { Editor } from '../editor';
import Toolbar from './Toolbar.vue';
import TransformControls from './TransformControls.vue';
import EditorSidebar from './EditorSidebar.vue';
const wrapperRef = ref();
const editorRef = shallowRef<Editor>(new Editor());
const resize = () => editorRef.value.viewport.setSizeFromWindow();
const resize = () => editorRef.value.viewport.setSizeFromViewport();
onMounted(() => {
editorRef.value.mount(wrapperRef.value);
window.addEventListener('resize', resize);
});
return () => {
window.removeEventListener('resize', resize);
editorRef.value?.stop();
};
onBeforeUnmount(() => {
window.removeEventListener('resize', resize);
editorRef.value?.stop();
});
</script>
<style>
.editor {
position: relative;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
.viewport {
overflow: hidden;
width: 100%;
height: 100%;
}
.editor-row {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="toolbar"></div>
</template>
<script setup lang="ts">
import { Editor } from '../editor';
const props = defineProps<{
editor: Editor;
}>();
const emit = defineEmits<{
(e: 'update'): void;
}>();
</script>
<style lang="scss">
.toolbar {
display: flex;
flex-direction: row;
height: 36px;
background-color: #efefef;
}
</style>

View File

@ -0,0 +1,75 @@
<template>
<div class="button-bar">
<button
v-for="mode of modes"
type="button"
@click="changeMode(mode.name)"
:class="{ active: mode.name === currentMode, 'mode-button': true }"
>
{{ mode.text }}
</button>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { Editor, TransformModeEvent } from '../editor';
import { useEditorEvents } from '../composables/use-editor-events';
const currentMode = ref<TransformModeEvent>('translate');
const modes: {
name: TransformModeEvent;
text: string;
}[] = [
{ name: 'translate', text: 'T' },
{ name: 'rotate', text: 'R' },
{ name: 'scale', text: 'S' },
];
const props = defineProps<{
editor: Editor;
}>();
const emit = defineEmits<{
(e: 'update'): void;
}>();
const { register } = useEditorEvents(props.editor);
function handleTransformMode(mode: TransformModeEvent) {
currentMode.value = mode;
}
function changeMode(mode: TransformModeEvent) {
props.editor.events.emit('transformMode', mode);
}
register('transformMode', handleTransformMode);
</script>
<style lang="scss">
.button-bar {
display: flex;
flex-direction: column;
position: absolute;
top: calc(32px + 36px);
left: 32px;
.mode-button {
appearance: none;
cursor: pointer;
border: 0;
padding: 8px;
background-color: #efefef;
&.active {
background-color: #ddd;
}
&:hover {
background-color: #ffffff;
}
}
}
</style>

View File

@ -0,0 +1,21 @@
<template>
<div class="sidebar-panel">
<div class="sidebar-panel-title"><slot name="title" /></div>
<div class="sidebar-panel-inner"><slot /></div>
</div>
</template>
<style lang="scss">
.sidebar-panel {
display: flex;
flex-direction: column;
&-title {
background-color: #e5e5e5;
padding: 8px;
text-transform: uppercase;
font-weight: bold;
color: #5a5a5a;
}
}
</style>

View File

@ -0,0 +1,66 @@
<template>
<div class="sidebar-row">
<button
type="button"
:class="{ selected: !!selected, 'sidebar-row-button': true }"
:style="{ paddingLeft: buttonPadding }"
@click="click($event)"
>
{{ item.name }}
</button>
<div class="sidebar-row-children">
<SidebarRow
v-for="object of filtered(item.children)"
:item="object"
:selectionMap="selectionMap"
:depth="depth + 1"
@select="(o, c) => emit('select', o, c)"
></SidebarRow>
</div>
</div>
</template>
<script setup lang="ts">
import { GameObject } from '@freeblox/engine';
import { Object3D } from 'three';
import { computed } from 'vue';
const props = defineProps<{
item: Object3D;
selectionMap: string[];
depth: number;
}>();
const emit = defineEmits<{
(e: 'update'): void;
(e: 'select', item: Object3D, ctrl: boolean): void;
}>();
const selected = computed(() => props.selectionMap?.includes(props.item.uuid));
const buttonPadding = computed(() => props.depth * 8 + 8 + 'px');
const click = ($event: MouseEvent) => {
emit('select', props.item, $event.ctrlKey);
};
const filtered = (items: Object3D[]) =>
items.filter((item) => item instanceof GameObject);
</script>
<style lang="scss">
.sidebar-row {
display: flex;
flex-direction: column;
&-button {
background-color: #efefef;
appearance: none;
padding: 8px;
border: 0;
text-align: left;
cursor: pointer;
&.selected {
background-color: #ffffff;
}
}
}
</style>

View File

@ -0,0 +1,32 @@
import { onBeforeUnmount, onMounted } from 'vue';
import { Editor, EditorEvents } from '../editor';
export const useEditorEvents = (editor: Editor) => {
const handlers: {
[x: string]: (...args: any) => void;
} = {};
const register = <E extends keyof EditorEvents>(
event: E,
fn: EditorEvents[E]
) => {
handlers[event as string] = fn;
};
onMounted(() => {
Object.keys(handlers).forEach((event) => {
editor.events.addListener(event as keyof EditorEvents, handlers[event]);
});
});
onBeforeUnmount(() => {
Object.keys(handlers).forEach((event) => {
editor.events.removeEventListener(
event as keyof EditorEvents,
handlers[event]
);
});
});
return { register };
};

View File

@ -5,6 +5,7 @@ import {
EventEmitter,
GameRunner,
Renderer,
LevelComponent,
} from '@freeblox/engine';
import { EditorEvents } from '../types/events';
import { WorkspaceComponent } from './workspace';
@ -18,6 +19,7 @@ export class Editor extends GameRunner {
public workspace!: WorkspaceComponent;
public mouse!: MouseComponent;
public environment!: EnvironmentComponent;
public level!: LevelComponent;
public running = false;
override mount(element: HTMLElement) {
@ -36,6 +38,10 @@ export class Editor extends GameRunner {
this.environment = new EnvironmentComponent(this.render, this.events);
this.environment.initialize();
this.level = new LevelComponent(this.render, this.events);
this.level.initialize();
this.viewport.setSizeFromViewport();
this.start();
}
@ -55,6 +61,7 @@ export class Editor extends GameRunner {
override start() {
this.running = true;
this.loop(this.lastTick);
this.events.emit('initialized');
}
override stop() {
@ -64,4 +71,12 @@ export class Editor extends GameRunner {
this.mouse.cleanUp();
this.render.cleanUp();
}
public getSceneTree() {
return this.level.getSceneTree();
}
public export(name: string) {
return this.level.serializeLevelSave(name);
}
}

View File

@ -1,7 +1,9 @@
import {
Brick,
ChangeEvent,
Cylinder,
EngineComponent,
Environment,
EventEmitter,
GameObject,
MouseButtonEvent,
@ -20,13 +22,19 @@ import {
Object3D,
Vector3,
} from 'three';
import { EditorEvents, SelectEvent, TransformModeEvent } from '../types/events';
import {
EditorEvents,
SelectEvent,
SelectionEvent,
TransformModeEvent,
} 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';
export class WorkspaceComponent extends EngineComponent {
public background = new Object3D();
public world = new Object3D();
public world = new World();
public helpers = new Object3D();
public orbitControls!: OrbitControls;
@ -40,7 +48,7 @@ export class WorkspaceComponent extends EngineComponent {
public transformScale?: Vector3;
public selection: Object3D[] = [];
public eventCleanUp?: Function;
public cleanUpEvents?: Function;
constructor(
protected renderer: Renderer,
@ -53,7 +61,7 @@ export class WorkspaceComponent extends EngineComponent {
this.addToScene(this.renderer.scene);
this.initializeHelpers();
this.initializeControls();
this.eventCleanUp = this.initializeSelector();
this.cleanUpEvents = this.bindEvents();
}
update(dt: number) {
@ -63,12 +71,11 @@ export class WorkspaceComponent extends EngineComponent {
cleanUp(): void {
this.removeFromScene(this.renderer.scene);
this.eventCleanUp?.call(this);
this.cleanUpEvents?.call(this);
}
private addToScene(scene: Object3D) {
this.background.name = '_background';
this.world.name = '_world';
this.helpers.name = '_helper';
scene.add(this.background, this.world, this.helpers);
@ -91,7 +98,7 @@ export class WorkspaceComponent extends EngineComponent {
scene.remove(this.background, this.world, this.helpers);
}
private initializeSelector() {
private bindEvents() {
let moved = false;
let clicked = false;
let casterDebounce: ReturnType<typeof setTimeout> | undefined;
@ -114,12 +121,64 @@ export class WorkspaceComponent extends EngineComponent {
casterDebounce = undefined;
clicked = false;
if (moved) return;
if (!event.target?.object) {
let object = event.target?.object;
if (object && !(object instanceof GameObject) && object.parent) {
object = object.parent;
}
this.events.emit('select', {
object: object as GameObject,
multi: event.control,
});
};
const selectedHandler = (select: SelectionEvent) => {
if (!select.picker) {
if (select.multi) {
this.selection.push(select.object);
return;
}
this.selection = [select.object];
}
const attachTo = this.selection.find(
(item) => !(item as GameObject).virtual
);
if (attachTo) {
this.transformControls?.attach(attachTo);
this.box.setFromObject(attachTo);
this.box.visible = true;
}
};
const deselectedHandler = (select: SelectionEvent) => {
if (!select.picker) {
const index = this.selection.indexOf(select.object);
this.selection.splice(index, 1);
}
const attachTo = this.selection.find(
(item) => !(item as GameObject).virtual
);
if (this.selection.length && attachTo) {
this.transformControls?.attach(attachTo);
this.box.setFromObject(attachTo);
this.box.visible = true;
return;
}
this.transformControls?.detach();
this.box.visible = false;
};
const selectHandler = (event: SelectEvent) => {
if (!event.object) {
if (!this.selection.length) return;
const oldSelection = this.selection;
this.selection = [];
oldSelection.forEach((selection) =>
this.events.emit('deselect', {
this.events.emit('deselected', {
object: selection,
selection: [],
picker: true,
@ -128,18 +187,12 @@ export class WorkspaceComponent extends EngineComponent {
return;
}
let object = event.target!.object;
if (
!(object instanceof GameObject) &&
object.parent instanceof GameObject
) {
object = object.parent;
}
let object = event.object;
if (this.selection.includes(object)) {
if (event.control) {
if (event.multi) {
const index = this.selection.indexOf(object);
this.selection.splice(index, 1);
this.events.emit('deselect', {
this.events.emit('deselected', {
object,
selection: this.selection,
picker: true,
@ -148,9 +201,9 @@ export class WorkspaceComponent extends EngineComponent {
}
}
if (event.control) {
if (event.multi) {
this.selection.push(object);
this.events.emit('select', {
this.events.emit('selected', {
object,
selection: this.selection,
multi: true,
@ -166,7 +219,7 @@ export class WorkspaceComponent extends EngineComponent {
const wasEmpty = !this.selection.length;
this.selection = [object];
if (wasEmpty) {
this.events.emit('select', {
this.events.emit('selected', {
object: object,
selection: [object],
picker: true,
@ -174,7 +227,7 @@ export class WorkspaceComponent extends EngineComponent {
}
notObject.forEach((entry) =>
this.events.emit('deselect', {
this.events.emit('deselected', {
object: entry,
selection: this.selection,
picker: true,
@ -182,39 +235,6 @@ export class WorkspaceComponent extends EngineComponent {
);
};
const selectHandler = (select: SelectEvent) => {
if (!select.picker) {
if (select.multi) {
this.selection.push(select.object);
return;
}
this.selection = [select.object];
}
const attachTo = this.selection[this.selection.length - 1];
this.transformControls?.attach(attachTo);
this.box.setFromObject(attachTo);
this.box.visible = true;
};
const deselectHandler = (select: SelectEvent) => {
if (!select.picker) {
const index = this.selection.indexOf(select.object);
this.selection.splice(index, 1);
}
if (this.selection.length) {
const attachTo = this.selection[this.selection.length - 1];
this.transformControls?.attach(attachTo);
this.box.setFromObject(attachTo);
this.box.visible = true;
return;
}
this.transformControls?.detach();
this.box.visible = false;
};
const transformMode = (mode: TransformModeEvent) => {
if (!this.transformControls) return;
if (!mode) {
@ -235,27 +255,39 @@ export class WorkspaceComponent extends EngineComponent {
this.transformControls.setRotationSnap(value);
};
const changeListener = (change: ChangeEvent) => {
if (change.object instanceof Environment) {
this.events.emit('setEnvironment', {
[change.property]: change.value,
});
}
};
this.events.addListener('mouseDown', mouseDownEventHandler);
this.events.addListener('mouseMove', mouseMoveEventHandler);
this.events.addListener('mouseUp', mouseUpEventHandler);
this.events.addListener('select', selectHandler);
this.events.addListener('deselect', deselectHandler);
this.events.addListener('selected', selectedHandler);
this.events.addListener('deselected', deselectedHandler);
this.events.addListener('transformMode', transformMode);
this.events.addListener('transformSnap', transformSnap);
this.events.addListener('transformRotationSnap', transformRotationSnap);
this.events.addListener('change', changeListener);
return () => {
this.events.removeEventListener('mouseDown', mouseDownEventHandler);
this.events.removeEventListener('mouseMove', mouseMoveEventHandler);
this.events.removeEventListener('mouseUp', mouseUpEventHandler);
this.events.removeEventListener('select', selectHandler);
this.events.removeEventListener('deselect', deselectHandler);
this.events.removeEventListener('selected', selectedHandler);
this.events.removeEventListener('deselected', deselectedHandler);
this.events.removeEventListener('transformMode', transformMode);
this.events.removeEventListener('transformSnap', transformSnap);
this.events.removeEventListener(
'transformRotationSnap',
transformRotationSnap
);
this.events.removeEventListener('change', changeListener);
};
}

View File

@ -1 +1,2 @@
export * from './core/editor';
export * from './types/events';

View File

@ -16,21 +16,18 @@ export interface TransformCompleteEvent extends TransformEvent {
lastScale: Vector3;
}
export interface ChangeEvent {
object: Object3D;
property: string;
value: any;
edited?: boolean;
transformed?: boolean;
}
export interface SelectEvent {
export interface SelectionEvent {
object: Object3D;
selection: Object3D[];
multi?: boolean;
picker?: boolean;
}
export interface SelectEvent {
object: Object3D;
multi?: boolean;
}
export type Events = {
transformStart: (event: TransformEvent) => void;
transformChange: (event: TransformCompleteEvent) => void;
@ -38,9 +35,9 @@ export type Events = {
transformMode: (event: TransformModeEvent) => void;
transformSnap: (event: number) => void;
transformRotationSnap: (event: number) => void;
change: (event: ChangeEvent) => void;
select: (event: SelectEvent) => void;
deselect: (event: SelectEvent) => void;
selected: (event: SelectionEvent) => void;
deselected: (event: SelectionEvent) => void;
};
export type EditorEvents = Events & EngineEvents;

View File

@ -6,3 +6,7 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}

View File

@ -0,0 +1 @@
export * from './manager';

View File

@ -0,0 +1,134 @@
import { CubeTexture, CubeTextureLoader, Texture, TextureLoader } from 'three';
import { Asset } from '../types/asset';
export class AssetManagerFactory {
public assets: Asset[] = [];
public texureLoader = new TextureLoader();
public cubeTextureLoader = new CubeTextureLoader();
/**
* Get an asset by path, this is an unique identifier
* @param path Asset path
*/
getAssetByPath(path: string) {
return this.assets.find((entry) => entry.path === path);
}
/**
* Create a new texture asset
* @param data Data URI
* @param name Asset name
* @param type Texture type
*/
async createAsset(
data: string,
name: string,
type: 'Texture' | 'CubeTexture' = 'Texture'
) {
const asset: Asset = {
name,
type,
data,
};
await this.load(asset);
asset.path = `fblxassetid:${asset.texture!.uuid}`;
this.assets.push(asset);
return asset;
}
/**
* Load asset into a Texture
* @param asset Remote or local asset data
*/
async load(asset: Asset) {
return (
asset.type === 'Texture'
? this.loadTextureData(
asset.remote ? asset.path : asset.data,
asset.name
)
: this.loadCubeTexture(
asset.remote ? asset.path : asset.data,
asset.name
)
).then((texture) => {
asset.texture = texture;
return asset;
});
}
/**
* Load assets into Textures
* @param assets Remote or local asset data
*/
async loadAll(assets: Asset[]) {
const loaded = await Promise.allSettled(
assets.map((item) => this.load(item))
);
loaded
.filter((entry) => entry.status === 'rejected')
.forEach((error) => console.error('Failed loading asset', error));
this.assets.push(
...loaded
.filter((entry) => entry.status === 'fulfilled')
.map((fulfilled) => (fulfilled as PromiseFulfilledResult<Asset>).value)
);
}
/**
* Serialize for save file without Texture data
*/
serialize(): Asset[] {
return this.assets.map((entry) => ({
name: entry.name,
path: entry.path,
data: !entry.remote ? entry.data : undefined,
type: entry.type,
}));
}
/**
* Load texture
* @param path Path
* @param name Texture name
*/
private async loadTextureData(path: string, name?: string) {
return new Promise<Texture>((resolve, reject) => {
this.texureLoader.load(
path,
(texture) => {
texture.name = name || 'Texture';
resolve(texture);
},
undefined,
reject
);
});
}
/**
* Load cube texture loader
* @param path pos-x, neg-x, pos-y, neg-y, pos-z, neg-z
* @param name Texture name
*/
private async loadCubeTexture(path: string[], name?: string) {
return new Promise<CubeTexture>((resolve, reject) => {
this.cubeTextureLoader.load(
path,
(texture) => {
texture.name = name || 'Texture';
resolve(texture);
},
undefined,
reject
);
});
}
}
export const assetManager = new AssetManagerFactory();

View File

@ -3,11 +3,13 @@ import { EngineEvents, EnvironmentEvent } from '../types/events';
import { EngineComponent } from '../types/engine-component';
import { Renderer } from '../core/renderer';
import { EventEmitter } from '../utils/events';
import { Environment } from '../gameobjects/environment.object';
export class EnvironmentComponent extends EngineComponent {
public ambient!: AmbientLight;
public directional!: DirectionalLight;
private handlerCleanUp?: Function;
public object = new Environment();
private cleanUpEvents?: Function;
constructor(
protected renderer: Renderer,
@ -17,6 +19,7 @@ export class EnvironmentComponent extends EngineComponent {
}
initialize(): void {
this.renderer.scene.add(this.object);
this.renderer.renderer.setClearColor(0x00aaff);
this.ambient = new AmbientLight(0x8a8a8a, 1.0);
@ -25,7 +28,7 @@ export class EnvironmentComponent extends EngineComponent {
this.renderer.scene.add(this.ambient);
this.renderer.scene.add(this.directional);
this.handlerCleanUp = this.initializeEvents();
this.cleanUpEvents = this.bindEvents();
}
update(delta: number): void {}
@ -33,10 +36,10 @@ export class EnvironmentComponent extends EngineComponent {
cleanUp(): void {
this.renderer.scene.remove(this.ambient);
this.renderer.scene.remove(this.directional);
this.handlerCleanUp?.call(this);
this.cleanUpEvents?.call(this);
}
private initializeEvents() {
private bindEvents() {
const setEnvironmentEvent = (event: EnvironmentEvent) => {
if (event.sunColor) this.directional.color = new Color(event.sunColor);
if (event.sunPosition) this.directional.position.copy(event.sunPosition);

View File

@ -1,3 +1,4 @@
export * from './environment';
export * from './viewport';
export * from './mouse';
export * from './level';

View File

@ -0,0 +1,58 @@
import { Renderer } from '../core/renderer';
import { EngineComponent } from '../types/engine-component';
import { EngineEvents } from '../types/events';
import { EventEmitter } from '../utils/events';
import { Environment } from '../gameobjects/environment.object';
import { assetManager } from '../assets/manager';
import { WorldFile } from '../types/world-file';
import { World } from '../gameobjects/world.object';
export class LevelComponent extends EngineComponent {
private world!: World;
private environment!: Environment;
private cleanUpEvents?: Function;
constructor(
protected renderer: Renderer,
protected events: EventEmitter<EngineEvents>
) {
super(renderer, events);
}
initialize(): void {
this.world = this.renderer.scene.getObjectByName('World') as World;
this.environment = this.renderer.scene.getObjectByName(
'Environment'
) as Environment;
this.cleanUpEvents = this.bindEvents();
}
update(delta: number): void {}
cleanUp(): void {
this.cleanUpEvents?.call(this);
}
public getSceneTree() {
return {
world: this.world,
environment: this.environment,
};
}
public serializeLevelSave(name: string): WorldFile {
const world = this.world.serialize();
const environment = this.environment.serialize();
const assets = assetManager.serialize();
return {
name,
world,
environment,
assets,
};
}
private bindEvents() {
return () => {};
}
}

View File

@ -3,12 +3,12 @@ import { EngineComponent } from '../types/engine-component';
import { Renderer } from '../core/renderer';
import { EngineEvents } from '../types/events';
import { EventEmitter } from '../utils/events';
import { World } from '../gameobjects/world.object';
type MouseMap = [boolean, boolean, boolean];
type EventMap = [string, Function];
export class MouseComponent extends EngineComponent {
private world!: Object3D;
private world!: World;
private mouseButtons: MouseMap = [false, false, false];
private mouseButtonsLast: MouseMap = [false, false, false];
@ -17,8 +17,9 @@ export class MouseComponent extends EngineComponent {
private mousePositionGL = new Vector2(0, 0);
private mousePositionLast = new Vector2(0, 0);
private mousePositionGLLast = new Vector2(0, 0);
private canvasOffset = new Vector2(0, 0);
private boundEvents: EventMap[] = [];
private cleanUpEvents?: Function;
private ray = new Raycaster();
constructor(
@ -32,9 +33,21 @@ export class MouseComponent extends EngineComponent {
return this.renderer.renderer.domElement;
}
initialize(): void {
this.world = this.renderer.scene.getObjectByName('_world')!;
initialize() {
this.world = this.renderer.scene.getObjectByName('World') as World;
this.cleanUpEvents = this.bindEvents();
}
update(delta: number): void {
this.ray.setFromCamera(this.mousePositionGL, this.renderer.camera);
this.mouseButtonsLast = [...this.mouseButtons];
}
cleanUp(): void {
this.cleanUpEvents?.call(this);
}
private bindEvents() {
const mouseDown = (ev: MouseEvent) => {
const [object] = this.ray.intersectObjects(this.world.children, true);
this.mouseButtons[ev.button] = true;
@ -67,7 +80,7 @@ export class MouseComponent extends EngineComponent {
this.mousePositionLast = this.mousePosition.clone();
this.mousePositionGLLast = this.mousePositionGL.clone();
this.mousePosition.set(ev.clientX, ev.clientY);
this.mousePosition.set(ev.clientX, ev.clientY).sub(this.canvasOffset);
this.mousePositionGL.set(
(this.mousePosition.x / this.renderer.resolution.x) * 2 - 1,
-(this.mousePosition.y / this.renderer.resolution.y) * 2 + 1
@ -83,27 +96,24 @@ export class MouseComponent extends EngineComponent {
const contextMenu = (ev: MouseEvent) => ev.preventDefault();
const onResize = () => {
const calculate = this.canvas.getBoundingClientRect();
this.canvasOffset.set(calculate.left, calculate.top);
};
this.canvas.addEventListener('mousedown', mouseDown);
this.canvas.addEventListener('mousemove', mouseMove);
this.canvas.addEventListener('mouseup', mouseUp);
this.canvas.addEventListener('contextmenu', contextMenu);
this.events.addListener('resize', onResize);
onResize();
this.boundEvents.push(
['mousedown', mouseDown],
['mousemove', mouseMove],
['mouseup', mouseUp],
['contextmenu', contextMenu]
);
}
update(delta: number): void {
this.ray.setFromCamera(this.mousePositionGL, this.renderer.camera);
this.mouseButtonsLast = [...this.mouseButtons];
}
cleanUp(): void {
for (const [event, handler] of this.boundEvents) {
this.canvas.removeEventListener(event, handler as EventListener);
}
return () => {
this.canvas.removeEventListener('mousedown', mouseDown);
this.canvas.removeEventListener('mousemove', mouseMove);
this.canvas.removeEventListener('mouseup', mouseUp);
this.canvas.removeEventListener('contextmenu', contextMenu);
this.events.removeEventListener('resize', onResize);
};
}
}

View File

@ -1,10 +1,12 @@
import { Object3D } from 'three';
import { Object3D, Vector2 } from 'three';
import { EngineEvents } from '../types/events';
import { EngineComponent } from '../types/engine-component';
import { Renderer } from '../core/renderer';
import { EventEmitter } from '../utils/events';
export class ViewportComponent extends EngineComponent {
private cleanUpEvents?: Function;
constructor(
protected render: Renderer,
protected events: EventEmitter<EngineEvents>
@ -21,13 +23,15 @@ export class ViewportComponent extends EngineComponent {
}
initialize() {
this.setSizeFromWindow();
this.cleanUpEvents = this.bindEvents();
this.camera.position.set(16, 8, 16);
}
update(dt: number) {}
cleanUp(): void {}
cleanUp(): void {
this.cleanUpEvents?.call(this);
}
setSize(width: number, height: number) {
this.render.viewport.style.width = `${width}px`;
@ -36,6 +40,31 @@ export class ViewportComponent extends EngineComponent {
}
setSizeFromWindow() {
this.setSize(window.innerWidth, window.innerHeight);
this.events.emit(
'resize',
new Vector2(window.innerWidth, window.innerHeight)
);
}
setSizeFromViewport() {
this.events.emit(
'resize',
new Vector2(
this.render.viewport.parentElement!.clientWidth,
this.render.viewport.parentElement!.clientHeight
)
);
}
private bindEvents() {
const resizeEvent = (size: Vector2) => {
this.setSize(size.x, size.y);
};
this.events.addListener('resize', resizeEvent);
return () => {
this.events.removeEventListener('resize', resizeEvent);
};
}
}

View File

@ -2,7 +2,6 @@ import {
BufferGeometry,
Color,
ColorRepresentation,
Material,
Mesh,
MeshPhongMaterial,
} from 'three';
@ -12,24 +11,29 @@ import {
gameObject3DEditorProperties,
} from '../types/game-object';
import { Property } from '../types/property';
import { gameObjectFactory } from './factory';
import { gameObjectGeometries } from './geometries';
import { assetManager } from '../assets/manager';
import { AssetInfo } from '../types/asset';
export const brickEditorProperties: EditorProperties = {
...gameObject3DEditorProperties,
color: new Property('color', Color, true, []),
transparency: new Property('transparency', Number, true, []),
texture: new Property('texture', AssetInfo, true, []),
};
export class Brick extends GameObject3D {
public objectType = Brick.name;
private texturePath?: string;
protected material = new MeshPhongMaterial();
protected mesh: Mesh = new Mesh(this.geometry, this.material);
constructor(
protected geometry: BufferGeometry = gameObjectFactory.boxGeometry,
protected geometry: BufferGeometry = gameObjectGeometries.boxGeometry,
public editorProperties: EditorProperties = brickEditorProperties
) {
super(editorProperties);
this.name = this.objectType;
this.add(this.mesh);
}
@ -47,4 +51,24 @@ export class Brick extends GameObject3D {
this.material.transparent = value != 0;
this.material.opacity = 1 - value;
}
set texture(path: string | undefined) {
if (!path) {
this.material.map = null;
this.texturePath = undefined;
return;
}
const asset = assetManager.getAssetByPath(path);
if (!asset || !asset.texture) {
console.error(`Asset ${path} does not exist or is not loaded`);
return;
}
this.texturePath = path;
this.material.map = asset.texture;
}
get texture() {
return this.texturePath;
}
}

View File

@ -1,13 +1,13 @@
import { Mesh, Color } from 'three';
import { Property } from '../types/property';
import { Mesh } from 'three';
import { Brick } from './brick.object';
import { gameObjectFactory } from './factory';
import { gameObjectGeometries } from './geometries';
export class Cylinder extends Brick {
public objectType = Cylinder.name;
protected mesh = new Mesh(this.geometry, this.material);
constructor() {
super(gameObjectFactory.cylinderGeometry);
super(gameObjectGeometries.cylinderGeometry);
this.name = this.objectType;
}
}

View File

@ -0,0 +1,46 @@
import { Color, Vector3 } from 'three';
import {
EditorProperties,
GameObject,
SerializedObject,
} from '../types/game-object';
import { Property } from '../types/property';
export const environmentEditorProperties: EditorProperties = {
sunColor: new Property('sunColor', Color, true, []),
sunPosition: new Property('sunPosition', Vector3, true, []),
sunStrength: new Property('sunStrength', Number, true, []),
ambientColor: new Property('ambientColor', Color, true, []),
ambientStrength: new Property('ambientStrength', Number, true, []),
clearColor: new Property('clearColor', Color, true, []),
};
export class Environment extends GameObject {
public objectType = Environment.name;
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);
constructor() {
super(environmentEditorProperties);
}
override serialize() {
return super.serialize() as SerializedEnvironment;
}
}
export interface SerializedEnvironment extends SerializedObject {
sunColor: Color;
sunPosition: Vector3;
sunStrength: number;
ambientColor: Color;
ambientStrength: number;
clearColor: Color;
}

View File

@ -1,6 +1,6 @@
import { BoxGeometry, CylinderGeometry, SphereGeometry } from 'three';
class GameObjectFactory {
class GameObjectGeometryFactory {
public boxGeometry = new BoxGeometry();
public sphereGeometry = new SphereGeometry(0.5);
public cylinderGeometry = new CylinderGeometry(0.5, 0.5);
@ -47,5 +47,5 @@ class GameObjectFactory {
}
}
export const gameObjectFactory = new GameObjectFactory();
Object.freeze(gameObjectFactory);
export const gameObjectGeometries = new GameObjectGeometryFactory();
Object.freeze(gameObjectGeometries);

View File

@ -1,6 +1,20 @@
export * from './brick.object';
export * from './cylinder.object';
export * from './sphere.object';
export * from './wedge.object';
export * from './wedge-corner.object';
export * from './wedge-inner-corner.object';
import { Cylinder } from './cylinder.object';
import { Brick } from './brick.object';
import { Sphere } from './sphere.object';
import { Wedge } from './wedge.object';
import { WedgeCorner } from './wedge-corner.object';
import { WedgeInnerCorner } from './wedge-inner-corner.object';
import { GameObject } from '../types/game-object';
import { Instancable } from '../types/instancable';
export const instancableGameObjects: Record<string, Instancable<GameObject>> = {
[Brick.name]: Brick,
[Cylinder.name]: Cylinder,
[Sphere.name]: Sphere,
[Wedge.name]: Wedge,
[WedgeCorner.name]: WedgeCorner,
[WedgeInnerCorner.name]: WedgeInnerCorner,
};
export * from './environment.object';
export { Cylinder, Brick, Sphere, Wedge, WedgeCorner, WedgeInnerCorner };

View File

@ -1,13 +1,13 @@
import { Mesh, Color } from 'three';
import { Property } from '../types/property';
import { Mesh } from 'three';
import { Brick } from './brick.object';
import { gameObjectFactory } from './factory';
import { gameObjectGeometries } from './geometries';
export class Sphere extends Brick {
public objectType = Sphere.name;
protected mesh = new Mesh(this.geometry, this.material);
constructor() {
super(gameObjectFactory.sphereGeometry);
super(gameObjectGeometries.sphereGeometry);
this.name = this.objectType;
}
}

View File

@ -1,13 +1,13 @@
import { Mesh, Color } from 'three';
import { Property } from '../types/property';
import { Mesh } from 'three';
import { Brick } from './brick.object';
import { gameObjectFactory } from './factory';
import { gameObjectGeometries } from './geometries';
export class WedgeCorner extends Brick {
public objectType = WedgeCorner.name;
protected mesh = new Mesh(this.geometry, this.material);
constructor() {
super(gameObjectFactory.wedgeCornerGeometry);
super(gameObjectGeometries.wedgeCornerGeometry);
this.name = this.objectType;
}
}

View File

@ -1,13 +1,13 @@
import { Mesh, Color } from 'three';
import { Property } from '../types/property';
import { Mesh } from 'three';
import { Brick } from './brick.object';
import { gameObjectFactory } from './factory';
import { gameObjectGeometries } from './geometries';
export class WedgeInnerCorner extends Brick {
public objectType = WedgeInnerCorner.name;
protected mesh = new Mesh(this.geometry, this.material);
constructor() {
super(gameObjectFactory.wedgeInnerCornerGeometry);
super(gameObjectGeometries.wedgeInnerCornerGeometry);
this.name = this.objectType;
}
}

View File

@ -1,13 +1,13 @@
import { Mesh, Color } from 'three';
import { Property } from '../types/property';
import { Mesh } from 'three';
import { Brick } from './brick.object';
import { gameObjectFactory } from './factory';
import { gameObjectGeometries } from './geometries';
export class Wedge extends Brick {
public objectType = Wedge.name;
protected mesh = new Mesh(this.geometry, this.material);
constructor() {
super(gameObjectFactory.wedgeGeometry);
super(gameObjectGeometries.wedgeGeometry);
this.name = this.objectType;
}
}

View File

@ -0,0 +1,7 @@
import { GameObject } from '../types/game-object';
export class World extends GameObject {
public objectType = World.name;
public name = 'World';
public virtual = true;
}

View File

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

View File

@ -0,0 +1,14 @@
import { Texture } from 'three';
export interface Asset {
name: string;
path?: string;
type: 'Texture' | 'CubeTexture';
texture?: Texture;
data: any;
remote?: boolean;
}
export class AssetInfo {
constructor(public path: string, public name: string) {}
}

View File

@ -5,6 +5,7 @@ import {
ColorRepresentation,
Vector3,
} from 'three';
import { WorldFile } from './world-file';
export interface MousePositionEvent {
position: Vector2;
@ -35,10 +36,26 @@ export interface EnvironmentEvent {
clearColor?: ColorRepresentation;
}
export interface ChangeEvent {
object: Object3D;
property: string;
value: any;
edited?: boolean;
transformed?: boolean;
}
export interface SceneTreeEvent {
world: Object3D;
environment: EnvironmentEvent;
}
export type EngineEvents = {
error: (error: Error) => void;
mouseDown: (event: MouseButtonEvent) => void;
mouseUp: (event: MouseButtonEvent) => void;
mouseMove: (event: MouseMoveEvent) => void;
setEnvironment: (event: EnvironmentEvent) => void;
change: (event: ChangeEvent) => void;
resize: (event: Vector2) => void;
initialized: () => void;
};

View File

@ -16,10 +16,13 @@ export const gameObject3DEditorProperties: EditorProperties = {
export class GameObject extends Object3D {
public objectType = 'GameObject';
public virtual = false;
constructor(
public editorProperties: EditorProperties = gameObjectEditorProperties
) {
super();
this.name = this.objectType;
}
/**
@ -50,6 +53,14 @@ export class GameObject extends Object3D {
return object;
}
parse(input: SerializedObject) {
Object.keys(input)
.filter((key) => key !== 'children')
.forEach((key) => {
(this as Record<string, unknown>)[key] = input[key];
});
}
}
export class GameObject3D extends GameObject {

View File

@ -2,3 +2,6 @@ export * from './game-runner';
export * from './events';
export * from './engine-component';
export * from './game-object';
export * from './instancable';
export * from './world-file';
export * from './asset';

View File

@ -0,0 +1 @@
export type Instancable<T> = { new (): T } | Function;

View File

@ -0,0 +1,10 @@
import { SerializedEnvironment } from '../gameobjects/environment.object';
import { Asset } from './asset';
import { SerializedObject } from './game-object';
export interface WorldFile {
name: string;
world: SerializedObject;
environment: SerializedEnvironment;
assets: Asset[];
}