hytopia
Version:
The HYTOPIA SDK makes it easy for developers to create massively multiplayer games using JavaScript or TypeScript.
606 lines (489 loc) • 16.9 kB
text/typescript
import {
Audio,
BaseEntityControllerEvent,
EventPayloads,
Player,
PlayerEntity,
PlayerCameraMode,
Vector3Like,
QuaternionLike,
World,
PlayerEntityController,
PlayerUIEvent,
} from 'hytopia';
import ChestEntity from './ChestEntity';
import GunEntity from './GunEntity';
import ItemEntity from './ItemEntity';
import PickaxeEntity from './weapons/PickaxeEntity';
import MeleeWeaponEntity from './MeleeWeaponEntity';
import { BUILD_BLOCK_ID } from '../gameConfig';
import GameManager from './GameManager';
const BASE_HEALTH = 100;
const BASE_SHIELD = 0;
const BLOCK_MATERIAL_COST = 3;
const INTERACT_RANGE = 4;
const MAX_HEALTH = 100;
const MAX_SHIELD = 100;
const TOTAL_INVENTORY_SLOTS = 6;
interface InventoryItem {
name: string;
iconImageUri: string;
quantity: number;
}
export default class GamePlayerEntity extends PlayerEntity {
private readonly _damageAudio: Audio;
private readonly _inventory: (ItemEntity | undefined)[] = new Array(TOTAL_INVENTORY_SLOTS).fill(undefined);
private _dead: boolean = false;
private _health: number = BASE_HEALTH;
private _inventoryActiveSlotIndex: number = 0;
private _maxHealth: number = MAX_HEALTH;
private _maxShield: number = MAX_SHIELD;
private _materials: number = 0;
private _respawnTimer: NodeJS.Timeout | undefined;
private _shield: number = BASE_SHIELD;
// Player entities always assign a PlayerController to the entity
public get playerController(): PlayerEntityController {
return this.controller as PlayerEntityController;
}
public get health(): number { return this._health; }
public set health(value: number) {
this._health = Math.max(0, Math.min(value, this._maxHealth));
this._updatePlayerUIHealth();
}
public get shield(): number { return this._shield; }
public set shield(value: number) {
this._shield = Math.max(0, Math.min(value, this._maxShield));
this._updatePlayerUIShield();
}
public get maxHealth(): number { return this._maxHealth; }
public get maxShield(): number { return this._maxShield; }
public get isDead(): boolean { return this._dead; }
public constructor(player: Player) {
super({
player,
name: 'Player',
modelUri: 'models/players/soldier-player.gltf',
modelScale: 0.5,
});
this._setupPlayerController();
this._setupPlayerUI();
this._setupPlayerCamera();
this._setupPlayerHeadshotCollider();
this._damageAudio = new Audio({
attachedToEntity: this,
uri: 'audio/sfx/player-hurt.mp3',
loop: false,
volume: 0.7,
});
}
public override spawn(world: World, position: Vector3Like, rotation?: QuaternionLike): void {
super.spawn(world, position, rotation);
this._setupPlayerInventory();
this._autoHealTicker();
this._outOfWorldTicker();
this._updatePlayerUIHealth();
}
public addItemToInventory(item: ItemEntity): void {
const slot = this._findInventorySlot();
if (slot === this._inventoryActiveSlotIndex) {
this.dropActiveInventoryItem();
}
this._inventory[slot] = item;
this._updatePlayerUIInventory();
this._updatePlayerUIInventoryActiveSlot();
this.setActiveInventorySlotIndex(this._inventoryActiveSlotIndex);
}
public addMaterial(quantity: number): void {
if (!quantity) return;
this._materials += quantity;
this._updatePlayerUIMaterials();
}
public checkDeath(attacker?: GamePlayerEntity): void {
if (this.health <= 0) {
this._dead = true;
if (attacker) {
GameManager.instance.addKill(attacker.player.username);
this.focusCameraOnPlayer(attacker);
}
this.dropAllInventoryItems();
if (this.isSpawned && this.world) {
// reset player inputs
Object.keys(this.player.input).forEach(key => {
this.player.input[key] = false;
});
this.playerController.idleLoopedAnimations = [ 'sleep' ];
this.world.chatManager.sendPlayerMessage(this.player, 'You have died! Respawning in 5 seconds...', 'FF0000');
this._respawnTimer = setTimeout(() => this.respawn(), 5 * 1000);
if (attacker) {
if (this.player.username !== attacker.player.username) {
this.world.chatManager.sendBroadcastMessage(`${attacker.player.username} killed ${this.player.username} with a ${attacker.getActiveItemName()}!`, 'FF0000');
} else {
this.world.chatManager.sendBroadcastMessage(`${this.player.username} self-destructed!`, 'FF0000');
}
}
}
}
}
public focusCameraOnPlayer(player: GamePlayerEntity): void {
this.player.camera.setMode(PlayerCameraMode.THIRD_PERSON);
this.player.camera.setAttachedToEntity(player);
this.player.camera.setModelHiddenNodes([]);
}
public dealtDamage(damage: number): void {
this.player.ui.sendData({
type: 'show-damage',
damage,
});
}
public dropActiveInventoryItem(): void {
if (this._inventoryActiveSlotIndex === 0) {
this.world?.chatManager?.sendPlayerMessage(this.player, 'You cannot drop your pickaxe!');
return;
}
const item = this._inventory[this._inventoryActiveSlotIndex];
if (!item) return;
item.unequip();
item.drop(this.position, this.player.camera.facingDirection);
this._inventory[this._inventoryActiveSlotIndex] = undefined;
this._updatePlayerUIInventory();
this._updatePlayerUIInventoryActiveSlot();
}
public dropAllInventoryItems(): void {
// skip 0, we cannot drop the pickaxe
for (let i = 1; i < this._inventory.length; i++) {
const item = this._inventory[i];
if (!item) continue;
item.unequip();
item.drop(this.position, this.player.camera.facingDirection);
this._inventory[i] = undefined;
}
this._updatePlayerUIInventory();
}
public getActiveItemName(): string {
const activeItem = this._inventory[this._inventoryActiveSlotIndex];
if (!activeItem) return '';
return activeItem.name;
}
public getItemInventorySlot(item: ItemEntity): number {
return this._inventory.findIndex(slot => slot === item);
}
public isItemActiveInInventory(item: ItemEntity): boolean {
return this._inventory[this._inventoryActiveSlotIndex] === item;
}
public resetAnimations(): void {
this.playerController.idleLoopedAnimations = ['idle_lower', 'idle_upper'];
this.playerController.interactOneshotAnimations = [];
this.playerController.walkLoopedAnimations = ['walk_lower', 'walk_upper'];
this.playerController.runLoopedAnimations = ['run_lower', 'run_upper'];
}
public resetCamera(): void {
this._setupPlayerCamera();
this.player.camera.setAttachedToEntity(this);
}
public resetMaterials(): void {
this._materials = 0;
this._updatePlayerUIMaterials();
}
public respawn(): void {
if (!this.world) return;
this._dead = false;
this.health = this._maxHealth;
this.shield = 0;
this.resetAnimations();
this.player.camera.setAttachedToEntity(this);
this._setupPlayerCamera();
this.setActiveInventorySlotIndex(0);
this.setPosition(GameManager.instance.getRandomSpawnPosition());
}
public setActiveInventorySlotIndex(index: number): void {
if (index !== this._inventoryActiveSlotIndex) {
this._inventory[this._inventoryActiveSlotIndex]?.unequip();
}
this._inventoryActiveSlotIndex = index;
if (this._inventory[index]) {
this._inventory[index].equip();
}
this._updatePlayerUIInventoryActiveSlot();
}
public setGravity(gravityScale: number): void {
this.setGravityScale(gravityScale);
}
public takeDamage(damage: number, hitDirection: Vector3Like, attacker?: GamePlayerEntity): void {
if (!this.isSpawned || !this.world || !GameManager.instance.isGameActive || this._dead) return;
this._playDamageAudio();
// Flash for damage
this.setTintColor({ r: 255, g: 0, b: 0});
setTimeout(() => this.setTintColor({ r: 255, g: 255, b: 255 }), 100); // reset tint color after 100ms
// Convert hit direction to screen space coordinates
const facingDir = this.player.camera.facingDirection;
this.player.ui.sendData({
type: 'damage-indicator',
direction: {
x: -(facingDir.x * hitDirection.z - facingDir.z * hitDirection.x),
y: 0,
z: -(facingDir.x * hitDirection.x + facingDir.z * hitDirection.z)
}
});
// Handle shield damage first
if (this.shield > 0) {
const shieldDamage = Math.min(damage, this.shield);
this.shield -= shieldDamage;
damage -= shieldDamage;
if (damage === 0) return;
}
// Handle health damage
this.health -= damage;
this.checkDeath(attacker);
}
public updateHealth(amount: number): void {
this.health = Math.min(this.health + amount, this._maxHealth);
this._updatePlayerUIHealth();
}
public updateShield(amount: number): void {
this.shield = Math.min(this.shield + amount, this._maxShield);
this._updatePlayerUIShield();
}
public updateItemInventoryQuantity(item: ItemEntity): void {
const index = this.getItemInventorySlot(item);
if (index === -1) return;
this.player.ui.sendData({
type: 'inventory-quantity-update',
index,
quantity: item.getQuantity(),
});
}
private _setupPlayerController(): void {
this.playerController.autoCancelMouseLeftClick = false;
this.resetAnimations();
this.playerController.on(BaseEntityControllerEvent.TICK_WITH_PLAYER_INPUT, this._onTickWithPlayerInput);
}
private _setupPlayerHeadshotCollider(): void {
// TODO
// this.createAndAddChildCollider({
// shape: ColliderShape.BALL,
// radius: 0.45,
// relativePosition: { x: 0, y: 0.4, z: 0 },
// isSensor: true,
// });
}
private _setupPlayerInventory(): void {
const pickaxe = new PickaxeEntity();
pickaxe.spawn(this.world!, this.position);
pickaxe.pickup(this);
}
private _setupPlayerUI(): void {
this.nametagSceneUI.setViewDistance(8); // lessen view distance so you only see player names when close
this.player.ui.load('ui/index.html');
// Handle inventory selection from mobile UI
this.player.ui.on(PlayerUIEvent.DATA, (payload) => {
const { data } = payload;
if (data.type === 'inventory-select') {
this.setActiveInventorySlotIndex(data.index);
}
});
}
private _setupPlayerCamera(): void {
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 });
}
private _onTickWithPlayerInput = (payload: EventPayloads[BaseEntityControllerEvent.TICK_WITH_PLAYER_INPUT]): void => {
const { input } = payload;
if (this._dead) {
return;
}
if (input.ml) {
this._handleMouseLeftClick();
}
if (input.mr) {
this._handleMouseRightClick();
}
if (input.e) {
this._handleInteract();
input.e = false;
}
if (input.q) {
this.dropActiveInventoryItem();
input.q = false;
}
if (input.r) {
this._handleReload();
input.r = false;
}
if (input.z) {
this._handleZoomScope();
input.z = false;
}
this._handleInventoryHotkeys(input);
}
private _handleMouseLeftClick(): void {
const activeItem = this._inventory[this._inventoryActiveSlotIndex];
if (activeItem instanceof ItemEntity && activeItem.consumable) {
activeItem.consume();
}
if (activeItem instanceof GunEntity) {
activeItem.shoot();
}
if (activeItem instanceof MeleeWeaponEntity) {
activeItem.attack();
}
}
private _handleMouseRightClick(): void {
this.player.input.mr = false;
if (!this.world) return;
if (this._materials < BLOCK_MATERIAL_COST) {
this.world?.chatManager?.sendPlayerMessage(this.player, `You need at least ${BLOCK_MATERIAL_COST} materials to build! Break blocks with your pickaxe to gather materials.`, 'FF0000');
return;
}
const { world } = this;
const position = this.position;
const facingDirection = this.player.camera.facingDirection;
const origin = {
x: position.x + (facingDirection.x * 0.5),
y: position.y + (facingDirection.y * 0.5) + this.player.camera.offset.y,
z: position.z + (facingDirection.z * 0.5),
};
const raycastHit = world.simulation.raycast(origin, facingDirection, 4, {
filterExcludeRigidBody: this.rawRigidBody,
});
if (raycastHit?.hitBlock) {
const { hitBlock } = raycastHit;
const placementCoordinate = hitBlock.getNeighborGlobalCoordinateFromHitPoint(raycastHit.hitPoint);
world.chunkLattice.setBlock(placementCoordinate, BUILD_BLOCK_ID);
this._materials -= BLOCK_MATERIAL_COST;
this._updatePlayerUIMaterials();
}
}
private _handleReload(): void {
const activeItem = this._inventory[this._inventoryActiveSlotIndex];
if (activeItem instanceof GunEntity) {
activeItem.reload();
}
}
private _handleZoomScope(): void {
const activeItem = this._inventory[this._inventoryActiveSlotIndex];
if (activeItem instanceof GunEntity) {
activeItem.zoomScope();
}
}
private _handleInventoryHotkeys(input: any): void {
if (input.f) {
this.setActiveInventorySlotIndex(0);
input.f = false;
}
for (let i = 1; i <= TOTAL_INVENTORY_SLOTS; i++) {
const key = i.toString();
if (input[key]) {
this.setActiveInventorySlotIndex(i);
input[key] = false;
}
}
}
private _handleInteract(): void {
if (!this.world) return;
const origin = {
x: this.position.x,
y: this.position.y + this.player.camera.offset.y,
z: this.position.z,
};
const raycastHit = this.world.simulation.raycast(
origin,
this.player.camera.facingDirection,
INTERACT_RANGE,
{
filterExcludeRigidBody: this.rawRigidBody,
filterFlags: 8, // Rapier exclude sensors,
}
);
const hitEntity = raycastHit?.hitEntity;
if (hitEntity instanceof ChestEntity) {
hitEntity.open();
}
if (hitEntity instanceof ItemEntity) {
if (this._findInventorySlot() === 0) {
this.world?.chatManager?.sendPlayerMessage(this.player, 'You cannot replace your pickaxe! Switch to a different item first to pick up this item.');
return;
}
hitEntity.pickup(this);
}
}
private _findInventorySlot(): number {
// Try active slot first if empty
if (!this._inventory[this._inventoryActiveSlotIndex]) {
return this._inventoryActiveSlotIndex;
}
// Find first empty slot or use active slot if none found
const emptySlot = this._inventory.findIndex(slot => !slot);
return emptySlot !== -1 ? emptySlot : this._inventoryActiveSlotIndex;
}
private _updatePlayerUIInventory(): void {
this.player.ui.sendData({
type: 'inventory',
inventory: this._inventory.map(item => {
if (!item) return;
return {
name: item.name,
iconImageUri: item.iconImageUri,
quantity: item.getQuantity(),
} as InventoryItem;
})
});
}
private _updatePlayerUIInventoryActiveSlot(): void {
this.player.ui.sendData({
type: 'inventory-active-slot',
index: this._inventoryActiveSlotIndex,
});
const activeItem = this._inventory[this._inventoryActiveSlotIndex];
if (activeItem instanceof GunEntity) {
activeItem.updateAmmoIndicatorUI();
} else {
this.player.ui.sendData({
type: 'ammo-indicator',
show: false,
});
}
}
private _updatePlayerUIHealth(): void {
this.player.ui.sendData({
type: 'health',
health: this._health,
maxHealth: this._maxHealth
});
}
private _updatePlayerUIMaterials(): void {
this.player.ui.sendData({
type: 'materials',
materials: this._materials,
});
}
private _updatePlayerUIShield(): void {
this.player.ui.sendData({
type: 'shield',
shield: this._shield,
maxShield: this._maxShield,
});
}
private _playDamageAudio(): void {
this._damageAudio.setDetune(-200 + Math.random() * 800);
this._damageAudio.play(this.world!, true);
}
private _autoHealTicker(): void {
setTimeout(() => {
if (!this.isSpawned) return;
if (this.health < this._maxHealth && !this._dead) {
this.health += 1;
}
this._autoHealTicker();
}, 2000);
}
private _outOfWorldTicker(): void {
setTimeout(() => {
if (!this.isSpawned) return;
if (this.position.y < -100 && !this._dead) {
this.takeDamage(MAX_HEALTH + MAX_SHIELD, { x: 0, y: 0, z: -1 });
}
this._outOfWorldTicker();
}, 3000);
}
}