restructure a bit, start work on client

This commit is contained in:
Evert Prants 2023-06-18 13:23:43 +03:00
parent 84c320faa2
commit 8350249591
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
29 changed files with 389 additions and 90 deletions

View File

@ -7,6 +7,7 @@
],
"main": "./dist/client.umd.cjs",
"module": "./dist/client.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/client.js",

View File

@ -1,3 +1,7 @@
<template>
<h1>test</h1>
<GameWrapper />
</template>
<script setup lang="ts">
import GameWrapper from './components/GameWrapper.vue';
</script>

View File

@ -1,3 +1,39 @@
<template>
<div class="game-wrapper"></div>
<div class="viewport">
<div class="canvas-wrapper" ref="wrapperRef"></div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { Game } from '../game';
import { ViewportComponent } from '@freeblox/engine';
const wrapperRef = ref();
const editorRef = shallowRef<Game>(new Game());
const resize = () =>
editorRef.value.getComponent(ViewportComponent).setSizeFromViewport();
onMounted(() => {
editorRef.value.mount(wrapperRef.value);
// TODO: for dev
editorRef.value.loadLevel('https://lunasqu.ee/freeblox/test-level.json');
window.addEventListener('resize', resize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', resize);
editorRef.value?.stop();
});
</script>
<style lang="scss">
.viewport {
position: relative;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
</style>

View File

@ -0,0 +1,42 @@
import {
EnvironmentComponent,
MouseComponent,
ViewportComponent,
EventEmitter,
LevelComponent,
Engine,
assetManager,
} from '@freeblox/engine';
import { GameEvents } from '../types/events';
import { GameplayComponent } from './gameplay';
export class Game extends Engine {
public events = new EventEmitter<GameEvents>();
mount(element: HTMLElement): void {
super.mount(element);
this.use(ViewportComponent);
this.use(EnvironmentComponent);
this.use(LevelComponent);
this.use(GameplayComponent);
this.use(MouseComponent);
this.getComponent(ViewportComponent).setSizeFromViewport();
this.start();
}
loop(now: number): void {
const delta = this.getDelta(now);
this.running && requestAnimationFrame((ts) => this.loop(ts));
this.update(delta);
this.render.render();
}
async loadLevel(path: string) {
this.events.emit('reset');
const data = await assetManager.loadJsonData(path);
await this.getComponent(LevelComponent).deserializeLevelSave(data);
this.events.emit('initialized');
}
}

View File

@ -0,0 +1,117 @@
import {
EngineComponent,
EventEmitter,
World,
instanceCharacterObject,
getCharacterController,
Humanoid,
} from '@freeblox/engine';
import { GameEvents } from '../types/events';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { Vector3 } from 'three';
/**
* Gameplay manager.
*/
export class GameplayComponent extends EngineComponent {
public name = GameplayComponent.name;
public events = new EventEmitter<GameEvents>();
public world!: World;
public cleanUpEvents?: () => void;
public characters: Humanoid[] = [];
public controls!: OrbitControls;
public character!: Humanoid;
public movementSpeed = 16;
public movement = {
forward: 0,
backward: 0,
left: 0,
right: 0,
};
override initialize(): void {
this.world = this.renderer.scene.getObjectByName('World') as World;
this.cleanUpEvents = this.bindEvents();
this.controls = new OrbitControls(
this.renderer.camera,
this.renderer.renderer.domElement
);
this.controls.enablePan = false;
this.loadCharacter('Diamond');
}
override update(delta: number): void {
this.controls.update();
for (const character of this.characters) {
character.tick(delta);
}
this.character?.setVelocity(
new Vector3(
this.movement.left + -this.movement.right,
0,
this.movement.forward + -this.movement.backward
)
);
this.character?.getWorldPosition(this.controls.target);
}
override cleanUp(): void {
this.cleanUpEvents?.();
}
public async loadCharacter(name: string, uuid?: string) {
const char = await instanceCharacterObject(name);
if (uuid) char.uuid = uuid;
const ctrl = getCharacterController(char);
this.world.add(char);
this.characters.push(ctrl);
ctrl.initialize();
this.character = ctrl;
}
private bindEvents() {
const keyDownEvent = (event: KeyboardEvent) => {
switch (event.code) {
case 'KeyW':
this.movement.forward = this.movementSpeed;
break;
case 'KeyS':
this.movement.backward = this.movementSpeed;
break;
case 'KeyA':
this.movement.left = this.movementSpeed;
break;
case 'KeyD':
this.movement.right = this.movementSpeed;
break;
}
};
const keyUpEvent = (event: KeyboardEvent) => {
switch (event.code) {
case 'KeyW':
this.movement.forward = 0;
break;
case 'KeyS':
this.movement.backward = 0;
break;
case 'KeyA':
this.movement.left = 0;
break;
case 'KeyD':
this.movement.right = 0;
break;
}
};
window.addEventListener('keydown', keyDownEvent);
window.addEventListener('keyup', keyUpEvent);
return () => {
window.removeEventListener('keydown', keyDownEvent);
window.removeEventListener('keyup', keyUpEvent);
};
}
}

View File

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

View File

@ -0,0 +1,5 @@
import { EngineEvents } from '@freeblox/engine';
export type Events = {};
export type GameEvents = Events & EngineEvents;

View File

@ -7,24 +7,16 @@ import {
Renderer,
LevelComponent,
WorldFile,
Engine,
} from '@freeblox/engine';
import { EditorEvents } from '../types/events';
import { WorkspaceComponent } from './workspace';
import { HistoryComponent } from './history';
import { ShortcutsComponent } from './shortcuts';
export class Editor extends GameRunner {
export class Editor extends Engine {
public lastTick = performance.now();
public events = new EventEmitter<EditorEvents>();
public render!: Renderer;
public element!: HTMLElement;
public viewport!: ViewportComponent;
public workspace!: WorkspaceComponent;
public history!: HistoryComponent;
public shortcuts!: ShortcutsComponent;
public mouse!: MouseComponent;
public environment!: EnvironmentComponent;
public level!: LevelComponent;
public running = false;
override mount(element: HTMLElement) {
@ -32,43 +24,25 @@ export class Editor extends GameRunner {
this.render = new Renderer(element);
this.render.renderer.autoClear = false;
this.viewport = new ViewportComponent(this.render, this.events);
this.viewport.initialize();
this.workspace = new WorkspaceComponent(this.render, this.events);
this.workspace.initialize();
this.history = new HistoryComponent(this.render, this.events);
this.history.initialize();
this.shortcuts = new ShortcutsComponent(this.render, this.events);
this.shortcuts.initialize();
this.mouse = new MouseComponent(this.render, this.events);
this.mouse.initialize();
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.use(ViewportComponent);
this.use(EnvironmentComponent);
this.use(LevelComponent);
this.use(WorkspaceComponent);
this.use(HistoryComponent);
this.use(ShortcutsComponent);
this.use(MouseComponent);
this.getComponent(ViewportComponent).setSizeFromViewport();
this.start();
}
override loop(now: DOMHighResTimeStamp) {
const delta = (now - this.lastTick) / 1000;
this.lastTick = now;
const delta = this.getDelta(now);
this.running && requestAnimationFrame((ts) => this.loop(ts));
this.viewport.update(delta);
this.workspace.update(delta);
this.mouse.update(delta);
this.update(delta);
this.render.render();
this.workspace.render();
this.getComponent(WorkspaceComponent).render();
}
override start() {
@ -77,22 +51,12 @@ export class Editor extends GameRunner {
this.events.emit('initialized');
}
override stop() {
this.running = false;
this.viewport.cleanUp();
this.workspace.cleanUp();
this.mouse.cleanUp();
this.render.cleanUp();
this.history.cleanUp();
this.shortcuts.cleanUp();
}
/**
* Get current scene tree for the level, starting with two virtual
* objects (world and environment).
*/
public getSceneTree() {
return this.level.getSceneTree();
return this.getComponent(LevelComponent).getSceneTree();
}
/**
@ -100,7 +64,7 @@ export class Editor extends GameRunner {
* @param name World name
*/
public export(name: string) {
return this.level.serializeLevelSave(name);
return this.getComponent(LevelComponent).serializeLevelSave(name);
}
/**
@ -108,13 +72,13 @@ export class Editor extends GameRunner {
* @param data World data
*/
public load(data: WorldFile) {
return this.level.deserializeLevelSave(data);
return this.getComponent(LevelComponent).deserializeLevelSave(data);
}
/**
* Get selected objects.
*/
public getSelection() {
return this.workspace.selection;
return this.getComponent(WorkspaceComponent).selection;
}
}

View File

@ -15,6 +15,7 @@ type HistoryType = [Object, Changes, number, number?];
* Manages history (undo, redo) for editor operations.
*/
export class HistoryComponent extends EngineComponent {
public name = HistoryComponent.name;
public cleanUpEvents?: Function;
private history: HistoryType[] = [];
private restory: HistoryType[] = [];

View File

@ -5,6 +5,7 @@ import { EditorEvents } from '..';
* Provides editing shortcuts for the editor.
*/
export class ShortcutsComponent extends EngineComponent {
public name = ShortcutsComponent.name;
public cleanUpEvents?: Function;
constructor(

View File

@ -1,7 +1,6 @@
import {
Brick,
CameraControls,
ChangeEvent,
Cylinder,
EngineComponent,
Environment,
EventEmitter,
@ -9,10 +8,7 @@ import {
GameObject3D,
MouseButtonEvent,
Renderer,
Sphere,
Wedge,
WedgeCorner,
WedgeInnerCorner,
TransformControls,
World,
} from '@freeblox/engine';
import {
@ -32,16 +28,17 @@ import {
TransformModeEvent,
} from '../types/events';
import { ViewHelper } from 'three/addons/helpers/ViewHelper.js';
import { CameraControls } from './controls/camera-controls';
import { TransformControls } from './controls/transform-controls';
/**
* This component does most of the work related to editing the level.
* Acts as a middle man for most events.
*
* Most importantly, handles selection, the clipboard and the editor scene.
*
* LevelComponent must be initialized before this component.
*/
export class WorkspaceComponent extends EngineComponent {
public name = WorkspaceComponent.name;
public background = new Object3D();
public world = new World();
public helpers = new Object3D();
@ -72,6 +69,7 @@ export class WorkspaceComponent extends EngineComponent {
}
initialize() {
this.world = this.renderer.scene.getObjectByName('World') as World;
this.addToScene(this.renderer.scene);
this.initializeHelpers();
this.initializeControls();
@ -102,11 +100,11 @@ export class WorkspaceComponent extends EngineComponent {
private addToScene(scene: Object3D) {
this.background.name = '_background';
this.helpers.name = '_helper';
scene.add(this.background, this.world, this.helpers);
scene.add(this.background, this.helpers);
}
private removeFromScene(scene: Object3D) {
scene.remove(this.background, this.world, this.helpers);
scene.remove(this.background, this.helpers);
}
private bindEvents() {

View File

@ -173,6 +173,10 @@ export class AssetManagerFactory extends EventEmitter<AssetsEvents> {
this.gltfLoader.load(path, (entry) => resolve(entry), undefined, reject);
});
}
async loadJsonData(path: string) {
return fetch(path).then((res) => res.json());
}
}
export const assetManager = new AssetManagerFactory();

View File

@ -6,10 +6,11 @@ import { EventEmitter } from '../utils/events';
import { Environment } from '../gameobjects/environment.object';
/**
* This component manages game environment and world lighting
* This component manages game environment and world lighting.
* @listens setEnvironment
*/
export class EnvironmentComponent extends EngineComponent {
public name = EnvironmentComponent.name;
public ambient!: AmbientLight;
public directional!: DirectionalLight;
public object = new Environment();

View File

@ -13,18 +13,21 @@ import { assetManager } from '../assets/manager';
import { WorldFile } from '../types/world-file';
import { World } from '../gameobjects/world.object';
import { instancableGameObjects } from '../gameobjects';
import { Color, Matrix4, Object3D, Vector3 } from 'three';
import { Object3D } from 'three';
import { GameObject, SerializedObject } from '../types/game-object';
import { environmentDefaults } from '../defaults/environment';
/**
* Game level management component
* Game level management component. This component provides the World.
*
* EnvironmentComponent must be initialized before this component.
* @listens change Applies changes to objects
* @listens remove Removes objects from scene
* @listens reparent Reparents object
*/
export class LevelComponent extends EngineComponent {
private world!: World;
public name = LevelComponent.name;
public world = new World();
private environment!: Environment;
private cleanUpEvents?: Function;
@ -36,7 +39,7 @@ export class LevelComponent extends EngineComponent {
}
initialize(): void {
this.world = this.renderer.scene.getObjectByName('World') as World;
this.renderer.scene.add(this.world);
this.environment = this.renderer.scene.getObjectByName(
'Environment'
) as Environment;

View File

@ -9,8 +9,11 @@ type MouseMap = [boolean, boolean, boolean];
/**
* Manage mouse and object picking from screen.
*
* ViewportComponent and LevelComponent must be initialized before this component.
*/
export class MouseComponent extends EngineComponent {
public name = MouseComponent.name;
private world!: World;
private mouseButtons: MouseMap = [false, false, false];
@ -52,7 +55,10 @@ export class MouseComponent extends EngineComponent {
private bindEvents() {
const mouseDown = (ev: MouseEvent) => {
const [object] = this.ray.intersectObjects(this.world.children, true);
const [object, ...rest] = this.ray.intersectObjects(
this.world.children,
true
);
this.mouseButtons[ev.button] = true;
this.events.emit('mouseDown', {
position: this.mousePosition,
@ -63,11 +69,15 @@ export class MouseComponent extends EngineComponent {
control: ev.ctrlKey,
alt: ev.altKey,
raw: ev,
otherTargets: rest,
});
};
const mouseUp = (ev: MouseEvent) => {
const [object] = this.ray.intersectObjects(this.world.children, true);
const [object, ...rest] = this.ray.intersectObjects(
this.world.children,
true
);
this.mouseButtons[ev.button] = false;
this.events.emit('mouseUp', {
position: this.mousePosition,
@ -78,6 +88,7 @@ export class MouseComponent extends EngineComponent {
control: ev.ctrlKey,
alt: ev.altKey,
raw: ev,
otherTargets: rest,
});
};

View File

@ -8,6 +8,7 @@ import { EventEmitter } from '../utils/events';
* Manage viewport sizing
*/
export class ViewportComponent extends EngineComponent {
public name = ViewportComponent.name;
private cleanUpEvents?: Function;
constructor(

View File

@ -9,6 +9,13 @@ const _changeEvent = { type: 'change' };
const _PI_2 = Math.PI / 2;
/**
* Freefly camera controls.
* WASD + QE to move around.
* Holding Right Mouse button rotates the view.
* Holding Middle Mouse button pans the view.
* Shift slows down movement.
*/
class CameraControls extends EventDispatcher {
private cameraMoving = false;
private cameraPanning = false;

View File

@ -0,0 +1,2 @@
export * from './camera-controls';
export * from './transform-controls';

View File

@ -80,6 +80,9 @@ class DefinedProperties extends Object3D {
public eye!: Vector3;
}
/**
* Transform controls - move, scale and rotate objects.
*/
class TransformControls extends DefinedProperties {
public isTransformControls = true;
public visible = false;

View File

@ -1,39 +1,65 @@
import { EngineComponent } from '..';
import { EngineComponent } from '../types/engine-component';
import { EngineEvents } from '../types/events';
import { GameRunner } from '../types/game-runner';
import { EventEmitter, EventMap } from '../utils/events';
import { Instancable } from '../types/instancable';
import { EventEmitter } from '../utils/events';
import { Renderer } from './renderer';
export class Engine<
TEvents extends EventMap = EngineEvents
> extends GameRunner {
export class Engine extends GameRunner {
public lastTick = performance.now();
public running = false;
public events = new EventEmitter<TEvents>();
public events = new EventEmitter<EngineEvents>();
public render!: Renderer;
public element!: HTMLElement;
public components: EngineComponent[] = [];
mount(element: HTMLElement): void {
override mount(element: HTMLElement): void {
this.element = element;
this.render = new Renderer(element);
this.render.renderer.autoClear = false;
}
override loop(now: number): void {
throw new Error('Method not implemented.');
}
override start(): void {
this.running = true;
this.loop(this.lastTick);
}
override stop(): void {
this.running = false;
for (const component of this.components) {
component.initialize();
component.cleanUp();
}
this.start();
}
loop(now: number): void {
throw new Error('Method not implemented.');
/**
* Update every component.
*/
update(dt: number) {
for (const component of this.components) {
component.update(dt);
}
}
start(): void {
throw new Error('Method not implemented.');
/**
* Use and initialize an engine component
* @param Component Engine Component
*/
use(Component: Instancable<EngineComponent>) {
const component = new Component(this.render, this.events);
this.components.push(component);
component.initialize();
return component;
}
stop(): void {
throw new Error('Method not implemented.');
/**
* Get an engine component.
* @param component Component
*/
getComponent<T extends EngineComponent>(component: Instancable<T>) {
return <T>this.components.find((cls) => cls.name === component.name);
}
}

View File

@ -1 +1,2 @@
export * from './renderer';
export * from './engine';

View File

@ -5,6 +5,7 @@ import {
Object3D,
Skeleton,
SkinnedMesh,
Vector3,
} from 'three';
import { GameObject } from '../types/game-object';
import { Ticking } from '../types/ticking';
@ -28,6 +29,7 @@ export class Humanoid extends GameObject implements Ticking {
private skeleton!: Skeleton;
private _health = 100;
private _maxHealth = 100;
private _velocity = new Vector3(0, 0, 0);
public static bodyPartNames = [
'Head',
'Torso',
@ -107,9 +109,14 @@ export class Humanoid extends GameObject implements Ticking {
action.play();
}
setVelocity(velocity: Vector3) {
this._velocity.copy(velocity);
}
tick(dt: number): void {
if (!this.ready) return;
this.mixer.update(dt);
this.parent?.position.add(this._velocity.clone().multiplyScalar(dt));
}
detach(bodyPart?: HumanoidBodyPart) {

View File

@ -6,3 +6,4 @@ export * from './gameobjects';
export * from './assets';
export * from './defaults/environment';
export * from './decorators';
export * from './controls';

View File

@ -3,6 +3,8 @@ import { EventEmitter } from '../utils/events';
import { EngineEvents } from './events';
export abstract class EngineComponent {
public abstract name: string;
constructor(
protected renderer: Renderer,
protected events: EventEmitter<EngineEvents>

View File

@ -26,6 +26,7 @@ export interface MouseButtonEvent extends MousePositionEvent {
shift?: boolean;
control?: boolean;
alt?: boolean;
otherTargets?: Intersection<Object3D>[];
}
export interface EnvironmentEvent {

View File

@ -4,7 +4,18 @@ import { EditorProperty } from '../decorators/property';
import { readMetadataOf } from '../utils/read-metadata';
export class GameObject extends Object3D {
/**
* Type of this Game Object.
*/
public objectType = 'GameObject';
/**
* Virtual game objects are usually built-in types that
* do not have physical representations in the game.
*
* These types still extend Object3D so they could
* have effects in the 3D world.
*/
public virtual = false;
@EditorProperty({ type: String, exposed: false, readonly: true })
@ -16,6 +27,10 @@ export class GameObject extends Object3D {
@EditorProperty({ type: Boolean })
public override visible: boolean = true;
/**
* Object gets included in exported files if `true`.
* @default true
*/
@EditorProperty({ type: Boolean })
public archivable: boolean = true;

View File

@ -1,6 +1,43 @@
export abstract class GameRunner {
/**
* Last tick length in milliseconds.
*/
public lastTick = performance.now();
/**
* Game running flag.
*/
public running = false;
/**
* Create the viewport and start the game.
* @param element Viewport element
*/
abstract mount(element: HTMLElement): void;
/**
* This is called on every frame of the game.
* Use this to render and update your components.
*/
abstract loop(now: DOMHighResTimeStamp): void;
/**
* Start running.
*/
abstract start(): void;
/**
* Stop running.
*/
abstract stop(): void;
/**
* Get delta time in seconds.
* @param now Time since last frame in milliseconds
*/
getDelta(now: number) {
const delta = (now - this.lastTick) / 1000;
this.lastTick = now;
return delta;
}
}

View File

@ -1,3 +1,3 @@
export interface Instancable<T> {
new (): T;
new (...args: any[]): T;
}

View File

@ -42,7 +42,7 @@ export const loadBaseCharacter = async () => {
return cachedMeta;
};
export const instanceCharacterObject = async () => {
export const instanceCharacterObject = async (name: string) => {
const base = await loadBaseCharacter();
const cloned = SkeletonUtils.clone(base.root!);
@ -73,14 +73,20 @@ export const instanceCharacterObject = async () => {
baseObject.animations = base.clips;
baseObject.add(bone as Bone);
baseObject.archivable = false;
baseObject.name = name;
convertedBodyParts.forEach((object) => baseObject.add(object));
convertedBodyParts.forEach((object) => (object.archivable = false));
head.texture = base.faceTexture;
const controller = new Humanoid();
controller.position.set(0, 4.75, 0);
controller.archivable = false;
baseObject.add(controller);
return baseObject;
};
export const getCharacterController = (object: Object3D) => {
return object.getObjectByName('Humanoid') as Humanoid;
};