diff --git a/packages/client/src/game/core/camera.ts b/packages/client/src/game/core/camera.ts index ca74a65..3ff0f56 100644 --- a/packages/client/src/game/core/camera.ts +++ b/packages/client/src/game/core/camera.ts @@ -15,8 +15,8 @@ export class ThirdPersonCamera { private prevMousePos = new Vector2(); private angleAroundPlayer = 180; - private pitch = -45; - private distance = 6; + private pitch = -24; + private distance = 8; private panning = false; private pinching = false; @@ -25,10 +25,14 @@ export class ThirdPersonCamera { private _moveFn?: (x: number, y: number) => void; + public minZoom = 2; + public maxZoom = 32; + constructor( private camera: PerspectiveCamera, private target: Object3D, - private eventTarget: HTMLElement + private eventTarget: HTMLElement, + private cameraOrignOffset = new Vector3(0, 4.75, 0) ) {} private dragEvent = (x: number, y: number) => { @@ -44,12 +48,12 @@ export class ThirdPersonCamera { this.angleAroundPlayer + ((offset.x * 0.3) % 360); } - this.pitch = clamp(this.pitch + offset.y * 0.3, -90, 90); + this.pitch = clamp(this.pitch + offset.y * 0.3, -89.9, 89.9); this.calculateCameraOffset(); } }; - events: Record void> = { + private events: Record void> = { contextmenu: (e: MouseEvent) => e.preventDefault(), mousedown: (e: MouseEvent) => { if (e.button === 2) { @@ -76,7 +80,7 @@ export class ThirdPersonCamera { mousemove: (e: MouseEvent) => this.dragEvent(e.clientX, e.clientY), wheel: (e: WheelEvent) => { e.deltaY < 0 ? (this.distance /= 1.2) : (this.distance *= 1.2); - this.distance = clamp(this.distance, 3, 20); + this.distance = clamp(this.distance, this.minZoom, this.maxZoom); this.calculateCameraOffset(); }, // mobile @@ -102,7 +106,7 @@ export class ThirdPersonCamera { if (this.previousPinchLength) { const delta = pinchLength / this.previousPinchLength; delta > 0 ? (this.distance *= delta) : (this.distance /= delta); - this.distance = clamp(this.distance, 3, 20); + this.distance = clamp(this.distance, this.minZoom, this.maxZoom); this.calculateCameraOffset(); } @@ -125,7 +129,9 @@ export class ThirdPersonCamera { initialize() { Object.keys(this.events).forEach((key) => { - this.eventTarget.addEventListener(key, this.events[key]); + this.eventTarget.addEventListener(key, this.events[key], { + passive: false, + }); }); this.calculateCameraOffset(); } @@ -171,12 +177,13 @@ export class ThirdPersonCamera { private getTargetOffset(): Vector3 { const offset = this.offsetFromPlayer.clone(); + offset.add(this.cameraOrignOffset); offset.add(this.target.position); return offset; } private getTargetLookAt(): Vector3 { - const offset = new Vector3(0, 4.75, 0); + const offset = this.cameraOrignOffset.clone(); offset.add(this.target.position); return offset; } diff --git a/packages/client/src/game/core/game.ts b/packages/client/src/game/core/game.ts index 47475e7..88f0d4d 100644 --- a/packages/client/src/game/core/game.ts +++ b/packages/client/src/game/core/game.ts @@ -34,9 +34,9 @@ export class Game extends Engine { } async loadLevel(path: string) { - this.events.emit('reset'); const data = await assetManager.loadJsonData(path); await this.getComponent(LevelComponent).deserializeLevelSave(data); + this.events.emit('initialized'); } } diff --git a/packages/client/src/game/core/gameplay.ts b/packages/client/src/game/core/gameplay.ts index 9150006..04042d3 100644 --- a/packages/client/src/game/core/gameplay.ts +++ b/packages/client/src/game/core/gameplay.ts @@ -8,8 +8,7 @@ import { } from '@freeblox/engine'; import { GameEvents } from '../types/events'; -import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; -import { Object3D, Vector3 } from 'three'; +import { Vector3 } from 'three'; import { ThirdPersonCamera } from './camera'; /** @@ -17,7 +16,6 @@ import { ThirdPersonCamera } from './camera'; */ export class GameplayComponent extends EngineComponent { public name = GameplayComponent.name; - public events = new EventEmitter(); public world!: World; public cleanUpEvents?: () => void; @@ -39,7 +37,6 @@ export class GameplayComponent extends EngineComponent { override initialize(): void { this.world = this.renderer.scene.getObjectByName('World') as World; this.cleanUpEvents = this.bindEvents(); - this.loadCharacter('Diamond'); } override update(delta: number): void { @@ -67,7 +64,7 @@ export class GameplayComponent extends EngineComponent { } } - override cleanUp(): void { + override dispose(): void { this.cleanUpEvents?.(); } @@ -122,11 +119,17 @@ export class GameplayComponent extends EngineComponent { } }; + const initializedEvent = () => { + this.loadCharacter('Diamond'); + }; + window.addEventListener('keydown', keyDownEvent); window.addEventListener('keyup', keyUpEvent); + this.events.addListener('initialized', initializedEvent); return () => { window.removeEventListener('keydown', keyDownEvent); window.removeEventListener('keyup', keyUpEvent); + this.events.removeEventListener('initialized', initializedEvent); }; } } diff --git a/packages/editor/src/editor/core/history.ts b/packages/editor/src/editor/core/history.ts index 80098f1..12490c9 100644 --- a/packages/editor/src/editor/core/history.ts +++ b/packages/editor/src/editor/core/history.ts @@ -33,7 +33,7 @@ export class HistoryComponent extends EngineComponent { update(delta: number): void {} - cleanUp(): void { + dispose(): void { this.cleanUpEvents?.call(this); this.history.length = 0; this.restory.length = 0; diff --git a/packages/editor/src/editor/core/shortcuts.ts b/packages/editor/src/editor/core/shortcuts.ts index 4da39d4..9d77957 100644 --- a/packages/editor/src/editor/core/shortcuts.ts +++ b/packages/editor/src/editor/core/shortcuts.ts @@ -21,7 +21,7 @@ export class ShortcutsComponent extends EngineComponent { update(delta: number): void {} - cleanUp(): void { + dispose(): void { this.cleanUpEvents?.call(this); } diff --git a/packages/editor/src/editor/core/workspace.ts b/packages/editor/src/editor/core/workspace.ts index b3e6521..0ffa9b7 100644 --- a/packages/editor/src/editor/core/workspace.ts +++ b/packages/editor/src/editor/core/workspace.ts @@ -90,7 +90,7 @@ export class WorkspaceComponent extends EngineComponent { this.viewHelper?.render(this.renderer.renderer); } - cleanUp(): void { + dispose(): void { this.removeFromScene(this.renderer.scene); this.cleanUpEvents?.call(this); this.viewHelper?.dispose(); diff --git a/packages/engine/src/canvas/index.ts b/packages/engine/src/canvas/index.ts new file mode 100644 index 0000000..04bca77 --- /dev/null +++ b/packages/engine/src/canvas/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/packages/engine/src/canvas/utils.ts b/packages/engine/src/canvas/utils.ts new file mode 100644 index 0000000..0aad635 --- /dev/null +++ b/packages/engine/src/canvas/utils.ts @@ -0,0 +1,227 @@ +import { CanvasTexture, LinearFilter, ClampToEdgeWrapping } from 'three'; + +export function rgbToHex(r: number, g: number, b: number): number { + return (1 << 24) + (r << 16) + (g << 8) + b; +} + +type RadiusQuad = { tl: number; tr: number; br: number; bl: number }; + +export interface CanvasUtilsOptions { + fill?: boolean; + backgroundColor?: string; + foregroundColor?: string; + rounded?: boolean; + roundedRadius?: number; + textBorderColor?: string; + textBorderSize?: number; + textShadowBlur?: number; +} + +export class CanvasUtils { + constructor(private options?: CanvasUtilsOptions) {} + + public createTextCanvas( + text: string | string[], + bold = true, + fontSize = 16, + padding = 4 + ): { texture: CanvasTexture; width: number; height: number } { + const ctx = document.createElement('canvas').getContext('2d')!; + const font = `${fontSize}px${bold ? ' bold' : ''} sans`; + + const lines = Array.isArray(text) ? text : [text]; + const lineWidths: number[] = []; + let longestLine = 0; + + // Measure the text bounds + ctx.font = font; + lines.forEach((line) => { + const lineWidth = ctx.measureText(line).width; + if (longestLine < lineWidth) { + longestLine = lineWidth; + } + lineWidths.push(lineWidth); + }); + + const width = longestLine + padding * 2; + const textHeight = fontSize * lines.length; + const height = textHeight + padding * 2; + + // Resize canvas + ctx.canvas.width = width; + ctx.canvas.height = height; + + // Set text parameters + ctx.font = font; + ctx.textAlign = 'center'; + + // Draw background + if (this.options?.fill ?? true) { + ctx.fillStyle = this.options?.backgroundColor || '#fff'; + if (this.options?.rounded) { + CanvasUtils.roundRect( + ctx, + 0, + 0, + width, + height, + this.options?.roundedRadius || 4, + true, + false + ); + } else { + ctx.fillRect(0, 0, width, height); + } + } + + // Scale the text to fit within the canvas + const scaleFactor = Math.min(1, width / longestLine); + ctx.translate( + Math.floor(width / 2 - padding) + 0.5, + Math.floor(padding + fontSize / 2) + 0.5 + ); + ctx.scale(scaleFactor, 1); + + // Draw the text + + if (this.options?.textShadowBlur !== undefined) { + ctx.shadowColor = this.options?.textBorderColor || '#000'; + ctx.shadowBlur = this.options?.textShadowBlur; + if (this.options?.textBorderSize !== undefined) { + ctx.lineWidth = this.options?.textBorderSize; + lines.forEach((line, i) => { + ctx.strokeText(line, padding, i * fontSize + padding); + }); + } + ctx.shadowBlur = 0; + } + + ctx.fillStyle = this.options?.foregroundColor || '#000'; + lines.forEach((line, i) => { + ctx.fillText(line, padding, i * fontSize + padding); + }); + + // Create texture with appropriate flags + const texture = new CanvasTexture(ctx.canvas); + texture.minFilter = LinearFilter; + texture.wrapS = ClampToEdgeWrapping; + texture.wrapT = ClampToEdgeWrapping; + + return { texture, width, height }; + } + + /** + * Draws a rounded rectangle using the current state of the canvas. + * If you omit the last three params, it will draw a rectangle + * outline with a 5 pixel border radius + * + * https://stackoverflow.com/a/3368118 + * + * @param {CanvasRenderingContext2D} ctx + * @param {Number} x The top left x coordinate + * @param {Number} y The top left y coordinate + * @param {Number} width The width of the rectangle + * @param {Number} height The height of the rectangle + * @param {Number} [radius = 5] The corner radius; It can also be an object + * to specify different radii for corners + * @param {Number} [radius.tl = 0] Top left + * @param {Number} [radius.tr = 0] Top right + * @param {Number} [radius.br = 0] Bottom right + * @param {Number} [radius.bl = 0] Bottom left + * @param {Boolean} [fill = false] Whether to fill the rectangle. + * @param {Boolean} [stroke = true] Whether to stroke the rectangle. + */ + public static roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius?: number | RadiusQuad, + fill?: boolean, + stroke?: boolean + ) { + if (typeof stroke === 'undefined') { + stroke = true; + } + if (typeof radius === 'undefined') { + radius = 5; + } + if (typeof radius === 'number') { + radius = { tl: radius, tr: radius, br: radius, bl: radius }; + } else { + const defaultRadius = { tl: 0, tr: 0, br: 0, bl: 0 }; + for (let side in defaultRadius) { + const sideKey = side as keyof RadiusQuad; + radius[sideKey] = radius[sideKey] || defaultRadius[sideKey]; + } + } + ctx.beginPath(); + ctx.moveTo(x + radius.tl, y); + ctx.lineTo(x + width - radius.tr, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr); + ctx.lineTo(x + width, y + height - radius.br); + ctx.quadraticCurveTo( + x + width, + y + height, + x + width - radius.br, + y + height + ); + ctx.lineTo(x + radius.bl, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl); + ctx.lineTo(x, y + radius.tl); + ctx.quadraticCurveTo(x, y, x + radius.tl, y); + ctx.closePath(); + if (fill) { + ctx.fill(); + } + if (stroke) { + ctx.stroke(); + } + } + + public static readPixelDataRGB(image: HTMLImageElement): number[] { + const array = new Array(image.width * image.height); + const ctx = document.createElement('canvas').getContext('2d')!; + ctx.canvas.width = image.width; + ctx.canvas.height = image.height; + ctx.drawImage(image, 0, 0, image.width, image.height); + + // pixel data + const data = ctx.getImageData(0, 0, image.width, image.height); + for (let x = 0; x < image.width; x++) { + for (let y = 0; y < image.height; y++) { + const index = x + y * image.width; + array[index] = rgbToHex( + data.data[index * 4], + data.data[index * 4 + 1], + data.data[index * 4 + 2] + ); + } + } + + return array; + } + + public static readPixelDataRScaled( + image: HTMLImageElement, + scale: number + ): number[] { + const array = new Array(image.width * image.height); + const ctx = document.createElement('canvas').getContext('2d')!; + ctx.canvas.width = image.width; + ctx.canvas.height = image.height; + ctx.drawImage(image, 0, 0, image.width, image.height); + + // pixel data + const data = ctx.getImageData(0, 0, image.width, image.height); + for (let x = 0; x < image.width; x++) { + for (let y = 0; y < image.height; y++) { + const index = x + y * image.width; + array[index] = (data.data[index * 4] * scale) / 255; + } + } + + return array; + } +} diff --git a/packages/engine/src/components/environment.ts b/packages/engine/src/components/environment.ts index f2e9d6d..30a722f 100644 --- a/packages/engine/src/components/environment.ts +++ b/packages/engine/src/components/environment.ts @@ -38,7 +38,7 @@ export class EnvironmentComponent extends EngineComponent { update(delta: number): void {} - cleanUp(): void { + dispose(): void { this.renderer.scene.remove(this.ambient); this.renderer.scene.remove(this.directional); this.cleanUpEvents?.call(this); diff --git a/packages/engine/src/components/level.ts b/packages/engine/src/components/level.ts index 6a63a7d..7032682 100644 --- a/packages/engine/src/components/level.ts +++ b/packages/engine/src/components/level.ts @@ -48,7 +48,7 @@ export class LevelComponent extends EngineComponent { update(delta: number): void {} - cleanUp(): void { + dispose(): void { this.cleanUpEvents?.call(this); } @@ -95,7 +95,6 @@ export class LevelComponent extends EngineComponent { public async deserializeLevelSave(save: WorldFile) { // Reset the world this.events.emit('reset'); - assetManager.freeAll(); // Load all assets await assetManager.loadAll(save.assets); diff --git a/packages/engine/src/components/mouse.ts b/packages/engine/src/components/mouse.ts index 542d691..9d4c48f 100644 --- a/packages/engine/src/components/mouse.ts +++ b/packages/engine/src/components/mouse.ts @@ -49,7 +49,7 @@ export class MouseComponent extends EngineComponent { this.mouseButtonsLast = [...this.mouseButtons]; } - cleanUp(): void { + dispose(): void { this.cleanUpEvents?.call(this); } diff --git a/packages/engine/src/components/viewport.ts b/packages/engine/src/components/viewport.ts index ceba20f..3ddb71f 100644 --- a/packages/engine/src/components/viewport.ts +++ b/packages/engine/src/components/viewport.ts @@ -33,7 +33,7 @@ export class ViewportComponent extends EngineComponent { update(dt: number) {} - cleanUp(): void { + dispose(): void { this.cleanUpEvents?.call(this); } diff --git a/packages/engine/src/core/engine.ts b/packages/engine/src/core/engine.ts index 984c3ee..81d0f7a 100644 --- a/packages/engine/src/core/engine.ts +++ b/packages/engine/src/core/engine.ts @@ -8,7 +8,7 @@ import { Renderer } from './renderer'; export class Engine extends GameRunner { public lastTick = performance.now(); public running = false; - public events = new EventEmitter(); + public events!: EventEmitter; public render!: Renderer; public element!: HTMLElement; public components: EngineComponent[] = []; @@ -31,7 +31,7 @@ export class Engine extends GameRunner { override stop(): void { this.running = false; for (const component of this.components) { - component.cleanUp(); + component.dispose(); } } diff --git a/packages/engine/src/core/renderer.ts b/packages/engine/src/core/renderer.ts index 8ece15c..72c6c9f 100644 --- a/packages/engine/src/core/renderer.ts +++ b/packages/engine/src/core/renderer.ts @@ -30,7 +30,7 @@ export class Renderer { this.renderer.render(this.scene, this.camera); } - cleanUp() { + dispose() { this.renderer.dispose(); } } diff --git a/packages/engine/src/gameobjects/humanoid.object.ts b/packages/engine/src/gameobjects/humanoid.object.ts index 73c75d1..a459c7d 100644 --- a/packages/engine/src/gameobjects/humanoid.object.ts +++ b/packages/engine/src/gameobjects/humanoid.object.ts @@ -12,6 +12,8 @@ import { Ticking } from '../types/ticking'; import { EditorProperty } from '../decorators/property'; import { MeshPart } from './mesh.object'; import { clamp } from 'three/src/math/MathUtils.js'; +import { NameTag } from './nametag.object'; +import { CanvasUtils } from '../canvas/utils'; export type HumanoidBodyPart = | 'Head' @@ -53,10 +55,20 @@ export class Humanoid extends GameObject implements Ticking { public static bodyAnimationNames = ['Idle', 'Walk']; + public static nameTagBuilder = new CanvasUtils({ + fill: false, + textBorderColor: '#000', + foregroundColor: '#fff', + textShadowBlur: 2, + textBorderSize: 1, + }); + private mixer!: AnimationMixer; private idleAction!: AnimationAction; private walkAction!: AnimationAction; + private nameTag?: NameTag; + @EditorProperty({ type: Number }) get health() { return this._health; @@ -114,6 +126,8 @@ export class Humanoid extends GameObject implements Ticking { this.idleAction = this.mixer.clipAction(idleClip); this.walkAction = this.mixer.clipAction(walkClip); this.idleAction.play(); + + this.createNameTag(); } setVelocity(velocity: Vector3) { @@ -173,4 +187,14 @@ export class Humanoid extends GameObject implements Ticking { this.health = 0; this.detach(); } + + private createNameTag() { + if (this.nameTag) { + this.nameTag.dispose(); + } + + this.nameTag = NameTag.create(Humanoid.nameTagBuilder, this.parent!.name); + this.nameTag.position.set(0, 1.5, 0); + this.add(this.nameTag); + } } diff --git a/packages/engine/src/gameobjects/index.ts b/packages/engine/src/gameobjects/index.ts index 8da644b..1f47895 100644 --- a/packages/engine/src/gameobjects/index.ts +++ b/packages/engine/src/gameobjects/index.ts @@ -26,6 +26,7 @@ export const instancableGameObjects: Record> = { export * from './environment.object'; export * from './world.object'; +export * from './nametag.object'; export { Group, Cylinder, diff --git a/packages/engine/src/gameobjects/nametag.object.ts b/packages/engine/src/gameobjects/nametag.object.ts new file mode 100644 index 0000000..c9ed7cb --- /dev/null +++ b/packages/engine/src/gameobjects/nametag.object.ts @@ -0,0 +1,47 @@ +import { Sprite, CanvasTexture, SpriteMaterial } from 'three'; +import { CanvasUtils } from '../canvas'; +import { GameObject } from '..'; + +export class NameTag extends GameObject { + public objectType = NameTag.name; + public virtual = true; + public archivable = false; + + public width!: number; + public height!: number; + private texture!: CanvasTexture; + private material!: SpriteMaterial; + + public static create(builder: CanvasUtils, name: string) { + const { texture, width, height } = builder.createTextCanvas( + name, + false, + 48 + ); + const obj = new NameTag(); + + obj.texture = texture; + obj.width = width; + obj.height = height; + + obj.material = new SpriteMaterial({ + map: texture, + transparent: true, + }); + + const label = new Sprite(obj.material); + + const labelBaseScale = 0.01; + label.scale.x = width * labelBaseScale; + label.scale.y = height * labelBaseScale; + + obj.add(label); + + return obj; + } + + dispose() { + this.material.dispose(); + this.texture.dispose(); + } +} diff --git a/packages/engine/src/types/engine-component.ts b/packages/engine/src/types/engine-component.ts index ca55aec..d8ec848 100644 --- a/packages/engine/src/types/engine-component.ts +++ b/packages/engine/src/types/engine-component.ts @@ -24,5 +24,5 @@ export abstract class EngineComponent { /** * Clean up the component */ - abstract cleanUp(): void; + abstract dispose(): void; } diff --git a/packages/engine/src/utils/character.ts b/packages/engine/src/utils/character.ts index 74a919a..9085284 100644 --- a/packages/engine/src/utils/character.ts +++ b/packages/engine/src/utils/character.ts @@ -1,10 +1,4 @@ -import { - AnimationClip, - Bone, - Object3D, - SkeletonHelper, - SkinnedMesh, -} from 'three'; +import { AnimationClip, Bone, Object3D, SkinnedMesh } from 'three'; import { assetManager } from '../assets'; import { Group } from '../gameobjects/group.object'; import { MeshPart } from '../gameobjects/mesh.object';