UNPKG

hytopia

Version:

The HYTOPIA SDK makes it easy for developers to create massively multiplayer games using JavaScript or TypeScript.

352 lines (283 loc) 9.83 kB
import { Audio, BaseEntityControllerEvent, CollisionGroup, EventPayloads, Light, LightType, Player, PlayerCameraOrientation, PlayerEntity, PlayerCameraMode, PlayerInput, SceneUI, Vector3Like, QuaternionLike, World, Quaternion, PlayerEntityController, Vector3, } from 'hytopia'; import PistolEntity from './guns/PistolEntity'; import InteractableEntity from './InteractableEntity'; import type GunEntity from './GunEntity'; import { INVISIBLE_WALL_COLLISION_GROUP } from '../gameConfig'; import GameManager from './GameManager'; const BASE_HEALTH = 100; const REVIVE_REQUIRED_HEALTH = 50; const REVIVE_PROGRESS_INTERVAL_MS = 1000; const REVIVE_DISTANCE_THRESHOLD = 3; export default class GamePlayerEntity extends PlayerEntity { public health: number; public maxHealth: number; public money: number; public downed = false; private _damageAudio: Audio; private _downedSceneUI: SceneUI; private _purchaseAudio: Audio; private _gun: GunEntity | undefined; private _light: Light; private _reviveInterval: NodeJS.Timeout | undefined; private _reviveDistanceVectorA: Vector3; private _reviveDistanceVectorB: Vector3; // Player entities always assign a PlayerController to the entity, so we can safely create a convenience getter public get playerController(): PlayerEntityController { return this.controller as PlayerEntityController; } public constructor(player: Player) { super({ player, name: 'Player', modelUri: 'models/players/soldier-player.gltf', modelScale: 0.5, }); // Prevent mouse left click from being cancelled, required // for auto-fire and semi-auto fire mechanics, etc. this.playerController.autoCancelMouseLeftClick = false; // Setup player animations this.playerController.idleLoopedAnimations = [ 'idle_lower' ]; this.playerController.interactOneshotAnimations = []; this.playerController.walkLoopedAnimations = ['walk_lower' ]; this.playerController.runLoopedAnimations = [ 'run_lower' ]; this.playerController.on(BaseEntityControllerEvent.TICK_WITH_PLAYER_INPUT, this._onTickWithPlayerInput); // Setup UI this.player.ui.load('ui/index.html'); // Setup first person camera this.player.camera.setMode(PlayerCameraMode.FIRST_PERSON); this.player.camera.setModelHiddenNodes([ 'head', 'neck', 'torso', 'leg_right', 'leg_left' ]); this.player.camera.setOffset({ x: 0, y: 0.5, z: 0 }); // Set base stats this.health = BASE_HEALTH; this.maxHealth = BASE_HEALTH; this.money = 0; // Setup damage audio this._damageAudio = new Audio({ attachedToEntity: this, uri: 'audio/sfx/player-hurt.mp3', loop: false, volume: 0.7, }); // Setup purchase audio this._purchaseAudio = new Audio({ attachedToEntity: this, uri: 'audio/sfx/purchase.mp3', loop: false, volume: 1, }); // Setup downed scene UI this._downedSceneUI = new SceneUI({ attachedToEntity: this, templateId: 'downed-player', offset: { x: 0, y: 0.5, z: 0 }, }); // Setup light this._light = new Light({ angle: Math.PI / 4 + 0.1, penumbra: 0.03, attachedToEntity: this, trackedEntity: this, type: LightType.SPOTLIGHT, intensity: 5, offset: { x: 0, y: 0, z: 0.1 }, color: { r: 255, g: 255, b: 255 }, }); // Create reusable vector3 for revive distance calculations this._reviveDistanceVectorA = new Vector3(0, 0, 0); this._reviveDistanceVectorB = new Vector3(0, 0, 0); } public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike): void { super.spawn(world, position, rotation); // Prevent players from colliding, setup appropriate collision groups for invisible walls, etc. this.setCollisionGroupsForSolidColliders({ belongsTo: [ CollisionGroup.PLAYER ], collidesWith: [ CollisionGroup.BLOCK, CollisionGroup.ENTITY, CollisionGroup.ENTITY_SENSOR, INVISIBLE_WALL_COLLISION_GROUP ], }); // Give player a pistol. this.equipGun(new PistolEntity({ parent: this })); // Spawn light this._light.spawn(world); // Start auto heal ticker this._autoHealTicker(); // Reset any prior UI from respawn this._updatePlayerUIHealth(); this._updatePlayerUIMoney(); } public addMoney(amount: number) { this.money += amount; this._updatePlayerUIMoney(); } public equipGun(gun: GunEntity) { if (!this.world) { return; } if (gun.isSpawned) { // no support for equipping already spawned guns atm, like pickup up guns etc, // but would be easy to add. Not needed for this game though. return console.warn('Cannot equip already spawned gun!'); } if (this._gun) { // despawn old gun this._gun.despawn(); } this._gun = gun; this._gun.spawn(this.world, { x: 0, y: 0, z: -0.2 }, Quaternion.fromEuler(-90, 0, 0)); } public spendMoney(amount: number): boolean { if (!this.world || this.money < amount) { return false; } this.money -= amount; this._updatePlayerUIMoney(); this._purchaseAudio.play(this.world, true); return true; } public takeDamage(damage: number) { if (!this.isSpawned || !this.world || this.downed) { return; } const healthAfterDamage = this.health - damage; if (this.health > 0 && healthAfterDamage <= 0) { this._setDowned(true); } this.health = Math.max(healthAfterDamage, 0); this._updatePlayerUIHealth(); // randomize the detune for variation each hit this._damageAudio.setDetune(-200 + Math.random() * 800); this._damageAudio.play(this.world, true); } public progressRevive(byPlayer: GamePlayerEntity) { if (!this.world) { return; } clearTimeout(this._reviveInterval); this._reviveInterval = setTimeout(() => { this._reviveDistanceVectorA.set([ this.position.x, this.position.y, this.position.z ]); this._reviveDistanceVectorB.set([ byPlayer.position.x, byPlayer.position.y, byPlayer.position.z ]); const distance = this._reviveDistanceVectorA.distance(this._reviveDistanceVectorB); if (distance > REVIVE_DISTANCE_THRESHOLD) { return; } this.health += 10; this._updatePlayerUIHealth(); this._downedSceneUI.setState({ progress: (this.health / REVIVE_REQUIRED_HEALTH) * 100, }); if (this.health >= REVIVE_REQUIRED_HEALTH) { this._setDowned(false); } else { this.progressRevive(byPlayer); } }, REVIVE_PROGRESS_INTERVAL_MS); } private _onTickWithPlayerInput = (payload: EventPayloads[BaseEntityControllerEvent.TICK_WITH_PLAYER_INPUT]) => { const { input } = payload; if (!this._gun) { return; } if (input.ml && !this.downed) { this._gun.shoot(); } if (input.r && !this.downed) { this._gun.reload(); input.r = false; } if (input.e) { this._interactRaycast(); input.e = false; } } private _setDowned(downed: boolean) { if (!this.world) { return; } this.downed = downed; if (downed) { this.health = 0; this._updatePlayerUIHealth(); } this.playerController.idleLoopedAnimations = downed ? [ 'sleep' ] : [ 'idle_lower' ]; this.playerController.walkLoopedAnimations = downed ? [ 'crawling' ] : [ 'walk_lower' ]; this.playerController.runLoopedAnimations = downed ? [ 'crawling' ] : [ 'run_lower' ]; this.playerController.runVelocity = downed ? 1 : 8; this.playerController.walkVelocity = downed ? 1 : 4; this.playerController.jumpVelocity = downed ? 0 : 10; if (!downed && this._gun) { this._gun.setParentAnimations(); } if (downed) { this._downedSceneUI.setState({ progress: 0 }) this._downedSceneUI.load(this.world); this.world.chatManager.sendPlayerMessage(this.player, 'You are downed! A teammate can still revive you!', 'FF0000'); GameManager.instance.checkEndGame(); } else { this._downedSceneUI.unload(); this.world.chatManager.sendPlayerMessage(this.player, 'You are back up! Thank your team & fight the horde!', '00FF00'); } } private _interactRaycast() { if (!this.world) { return; } if (this.downed) { return this.world.chatManager.sendPlayerMessage(this.player, 'You are downed! You cannot revive others or make purchases!', 'FF0000'); } // Get raycast direction from player camera const origin = { x: this.position.x, y: this.position.y + this.player.camera.offset.y, z: this.position.z, }; const direction = this.player.camera.facingDirection; const length = 4; const raycastHit = this.world.simulation.raycast(origin, direction, length, { filterExcludeRigidBody: this.rawRigidBody, // prevent raycast from hitting the player }); const hitEntity = raycastHit?.hitEntity; if (!hitEntity) { return; } if (hitEntity instanceof InteractableEntity) { hitEntity.interact(this); } if (hitEntity instanceof GamePlayerEntity && hitEntity.downed) { hitEntity.progressRevive(this); } } private _updatePlayerUIMoney() { this.player.ui.sendData({ type: 'money', money: this.money }); } private _updatePlayerUIHealth() { this.player.ui.sendData({ type: 'health', health: this.health, maxHealth: this.maxHealth }); } private _autoHealTicker() { setTimeout(() => { if (!this.isSpawned) { return; } if (!this.downed && this.health < this.maxHealth) { this.health += 1; this._updatePlayerUIHealth(); } this._autoHealTicker(); }, 1000); } }