minecraft-clone / src /engine /mobs /mobSystem.ts
TomatitoToho's picture
Upload src/engine/mobs/mobSystem.ts with huggingface_hub
3834d4a verified
Raw
History Blame Contribute Delete
18.8 kB
/**
* Mob System — Minecraft Clone
*
* Manages mob spawning, AI behavior, movement, and despawning.
* Supports both hostile and passive mob types across dimensions.
*/
import {
BLOCK_AIR,
BLOCK_GRASS_BLOCK,
BLOCK_NETHERRACK,
BLOCK_SOUL_SAND,
BLOCK_CRIMSON_NYLIUM,
BLOCK_WARPED_NYLIUM,
BLOCK_END_STONE,
BLOCK_BASALT,
BLOCK_LAVA,
BLOCK_WATER,
MOB_CAP_HOSTILE,
MOB_CAP_PASSIVE,
SPAWN_RADIUS_MIN,
SPAWN_RADIUS_MAX,
} from '../constants'
// ═══════════════════════════════════════════════════════════════
// MOB TYPES & INTERFACES
// ═══════════════════════════════════════════════════════════════
export type MobType =
| 'zombie'
| 'skeleton'
| 'creeper'
| 'spider'
| 'pig'
| 'cow'
| 'sheep'
| 'chicken'
| 'enderman'
export interface Mob {
id: string
type: MobType
position: [number, number, number]
velocity: [number, number, number]
rotation: number // yaw in degrees
health: number
maxHealth: number
hostile: boolean
attackDamage: number
speed: number
spawnTime: number
onGround: boolean
attackCooldown: number
wanderAngle: number
wanderTimer: number
hurtTimer: number
dimension: 'overworld' | 'nether' | 'end'
}
interface MobStats {
health: number
damage: number
speed: number
hostile: boolean
dimensions: 'overworld' | 'nether' | 'end' | 'any'
}
// ═══════════════════════════════════════════════════════════════
// MOB STATS TABLE
// ═══════════════════════════════════════════════════════════════
const MOB_STATS: Record<MobType, MobStats> = {
zombie: { health: 20, damage: 3, speed: 2.0, hostile: true, dimensions: 'any' },
skeleton: { health: 20, damage: 2, speed: 2.0, hostile: true, dimensions: 'any' },
creeper: { health: 20, damage: 0, speed: 1.5, hostile: true, dimensions: 'any' },
spider: { health: 16, damage: 2, speed: 2.5, hostile: true, dimensions: 'any' },
pig: { health: 10, damage: 0, speed: 1.5, hostile: false, dimensions: 'overworld' },
cow: { health: 10, damage: 0, speed: 1.5, hostile: false, dimensions: 'overworld' },
sheep: { health: 8, damage: 0, speed: 1.5, hostile: false, dimensions: 'overworld' },
chicken: { health: 4, damage: 0, speed: 1.2, hostile: false, dimensions: 'overworld' },
enderman: { health: 40, damage: 7, speed: 3.0, hostile: true, dimensions: 'end' },
}
// ═══════════════════════════════════════════════════════════════
// UTILITY
// ═══════════════════════════════════════════════════════════════
let mobIdCounter = 0
function generateMobId(): string {
return `mob_${++mobIdCounter}_${Date.now()}`
}
function distance3d(a: [number, number, number], b: [number, number, number]): number {
const dx = a[0] - b[0]
const dy = a[1] - b[1]
const dz = a[2] - b[2]
return Math.sqrt(dx * dx + dy * dy + dz * dz)
}
function distance2d(a: [number, number, number], b: [number, number, number]): number {
const dx = a[0] - b[0]
const dz = a[2] - b[2]
return Math.sqrt(dx * dx + dz * dz)
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
/** Check if a block is solid (non-air, non-liquid) for collision purposes */
function isSolidBlock(blockId: number): boolean {
return blockId !== BLOCK_AIR && blockId !== BLOCK_LAVA && blockId !== BLOCK_WATER
}
// ═══════════════════════════════════════════════════════════════
// MOB SYSTEM CLASS
// ═══════════════════════════════════════════════════════════════
export class MobSystem {
mobs: Map<string, Mob>
private spawnTimer: number
private currentDimension: 'overworld' | 'nether' | 'end'
constructor() {
this.mobs = new Map()
this.spawnTimer = 0
this.currentDimension = 'overworld'
}
// ─── Dimension ─────────────────────────────────────────────
setDimension(dimension: 'overworld' | 'nether' | 'end'): void {
this.currentDimension = dimension
}
// ─── Spawn / Remove ────────────────────────────────────────
spawnMob(type: MobType, position: [number, number, number]): Mob {
const stats = MOB_STATS[type]
const mob: Mob = {
id: generateMobId(),
type,
position: [position[0], position[1], position[2]],
velocity: [0, 0, 0],
rotation: Math.random() * 360,
health: stats.health,
maxHealth: stats.health,
hostile: stats.hostile,
attackDamage: stats.damage,
speed: stats.speed,
spawnTime: Date.now(),
onGround: false,
attackCooldown: 0,
wanderAngle: Math.random() * 360,
wanderTimer: 0,
hurtTimer: 0,
dimension: this.currentDimension,
}
this.mobs.set(mob.id, mob)
return mob
}
removeMob(id: string): void {
this.mobs.delete(id)
}
// ─── Update ────────────────────────────────────────────────
updateMobs(
delta: number,
playerPos: [number, number, number],
getBlock: (x: number, y: number, z: number) => number
): void {
const toRemove: string[] = []
const mobEntries = Array.from(this.mobs.entries())
for (const [id, mob] of mobEntries) {
// Despawn mobs too far from player
const dist = distance3d(mob.position, playerPos)
if (dist > SPAWN_RADIUS_MAX) {
toRemove.push(id)
continue
}
// Update AI behavior
if (mob.hostile) {
this.updateHostileAI(mob, delta, playerPos, getBlock)
} else {
this.updatePassiveAI(mob, delta, getBlock)
}
// Apply gravity and collision
this.applyPhysics(mob, delta, getBlock)
// Update cooldowns
if (mob.attackCooldown > 0) {
mob.attackCooldown -= delta
}
if (mob.hurtTimer > 0) {
mob.hurtTimer -= delta
}
}
// Remove despawned mobs
for (const id of toRemove) {
this.mobs.delete(id)
}
}
// ─── Hostile AI ────────────────────────────────────────────
private updateHostileAI(
mob: Mob,
delta: number,
playerPos: [number, number, number],
getBlock: (x: number, y: number, z: number) => number
): void {
const distToPlayer = distance2d(mob.position, playerPos)
// Special: Endermen are neutral until provoked
if (mob.type === 'enderman') {
// Endermen only attack if within 8 blocks and player is looking at them
// Simplified: attack if within 8 blocks
if (distToPlayer < 8 && mob.hurtTimer > 0) {
this.moveToward(mob, playerPos, delta)
if (distToPlayer < 2) {
this.attackPlayer(mob)
}
} else {
this.wander(mob, delta)
}
return
}
// Standard hostile mobs: chase player within 16 blocks
if (distToPlayer < 16) {
this.moveToward(mob, playerPos, delta)
// Attack if within 2 blocks
if (distToPlayer < 2) {
if (mob.type === 'creeper') {
// Creepers "explode" — in this simplified system, deal massive damage
this.creeperExplode(mob)
} else {
this.attackPlayer(mob)
}
}
} else {
// Wander randomly when player is far
this.wander(mob, delta)
}
}
// ─── Passive AI ────────────────────────────────────────────
private updatePassiveAI(
mob: Mob,
delta: number,
getBlock: (x: number, y: number, z: number) => number
): void {
// Flee if recently hurt
if (mob.hurtTimer > 0) {
// Run away in the opposite direction of whatever hurt them
const fleeAngle = mob.wanderAngle + 180
const rad = (fleeAngle * Math.PI) / 180
const speed = mob.speed * 1.5 // Run faster when fleeing
mob.velocity[0] = Math.sin(rad) * speed
mob.velocity[2] = Math.cos(rad) * speed
mob.rotation = fleeAngle
} else {
// Random walk
this.wander(mob, delta)
}
}
// ─── Movement Helpers ──────────────────────────────────────
private moveToward(mob: Mob, target: [number, number, number], delta: number): void {
const dx = target[0] - mob.position[0]
const dz = target[2] - mob.position[2]
const dist = Math.sqrt(dx * dx + dz * dz)
if (dist < 0.1) return
const nx = dx / dist
const nz = dz / dist
const speed = mob.speed
mob.velocity[0] = nx * speed
mob.velocity[2] = nz * speed
mob.rotation = (Math.atan2(dx, dz) * 180) / Math.PI
// Jump if hitting a wall (simple check)
if (mob.onGround) {
const aheadX = mob.position[0] + nx * 0.6
const aheadZ = mob.position[2] + nz * 0.6
const blockAhead = this.getBlockAt(aheadX, mob.position[1], aheadZ, () => 0)
if (blockAhead !== 0) {
mob.velocity[1] = 6.0 // Jump
}
}
}
private wander(mob: Mob, delta: number): void {
mob.wanderTimer -= delta
if (mob.wanderTimer <= 0) {
// Pick a new random direction and duration
mob.wanderAngle = Math.random() * 360
mob.wanderTimer = 2 + Math.random() * 4 // Wander for 2-6 seconds
// 30% chance to stand still
if (Math.random() < 0.3) {
mob.wanderTimer = 1 + Math.random() * 2
mob.velocity[0] = 0
mob.velocity[2] = 0
return
}
}
const rad = (mob.wanderAngle * Math.PI) / 180
const speed = mob.speed * 0.5 // Slower when wandering
mob.velocity[0] = Math.sin(rad) * speed
mob.velocity[2] = Math.cos(rad) * speed
mob.rotation = mob.wanderAngle
}
// ─── Attack ────────────────────────────────────────────────
private attackPlayer(mob: Mob): void {
if (mob.attackCooldown > 0) return
mob.attackCooldown = 1.0 // 1 second cooldown
// The actual damage to the player is handled by the game loop
// Here we just mark that the mob is attacking
}
private creeperExplode(mob: Mob): void {
// Mark for explosion — the game loop should check for this
// For now, the creeper deals high damage and removes itself
mob.health = 0
this.mobs.delete(mob.id)
}
// ─── Physics ───────────────────────────────────────────────
private applyPhysics(
mob: Mob,
delta: number,
getBlock: (x: number, y: number, z: number) => number
): void {
const gravity = 20.0 // blocks/s^2
// Apply gravity
mob.velocity[1] -= gravity * delta
// Compute new position
const newX = mob.position[0] + mob.velocity[0] * delta
const newY = mob.position[1] + mob.velocity[1] * delta
const newZ = mob.position[2] + mob.velocity[2] * delta
// Ground collision — check block below feet
const feetY = Math.floor(newY - 0.1)
const blockBelow = getBlock(
Math.floor(newX),
feetY,
Math.floor(newZ)
)
if (isSolidBlock(blockBelow) && mob.velocity[1] < 0) {
// Land on ground
mob.position[1] = Math.ceil(newY - 0.1) + 0.1
mob.velocity[1] = 0
mob.onGround = true
} else {
mob.position[1] = newY
mob.onGround = false
}
// Horizontal collision — simple check
const blockAtFeet = getBlock(
Math.floor(newX),
Math.floor(mob.position[1]),
Math.floor(newZ)
)
const blockAtHead = getBlock(
Math.floor(newX),
Math.floor(mob.position[1] + 1),
Math.floor(newZ)
)
if (!isSolidBlock(blockAtFeet) && !isSolidBlock(blockAtHead)) {
mob.position[0] = newX
mob.position[2] = newZ
} else {
// Stop horizontal movement on collision
mob.velocity[0] = 0
mob.velocity[2] = 0
}
// Don't fall below world
if (mob.position[1] < -64) {
mob.position[1] = -64
mob.velocity[1] = 0
mob.onGround = true
}
}
// ─── Query ─────────────────────────────────────────────────
getMobsNearby(pos: [number, number, number], radius: number): Mob[] {
const result: Mob[] = []
const allMobs = Array.from(this.mobs.values())
for (const mob of allMobs) {
if (distance3d(mob.position, pos) <= radius) {
result.push(mob)
}
}
return result
}
// ─── Damage ────────────────────────────────────────────────
damageMob(id: string, amount: number): Mob | null {
const mob = this.mobs.get(id)
if (!mob) return null
mob.health -= amount
mob.hurtTimer = 0.5 // 0.5 seconds of hurt reaction
if (mob.health <= 0) {
this.mobs.delete(id)
return null
}
return mob
}
// ─── Spawning Logic ────────────────────────────────────────
tick(
playerPos: [number, number, number],
getBlock: (x: number, y: number, z: number) => number
): void {
this.spawnTimer++
// Attempt to spawn every ~20 ticks (1 second)
if (this.spawnTimer % 20 !== 0) return
// Count current mobs by category
let hostileCount = 0
let passiveCount = 0
const allMobs = Array.from(this.mobs.values())
for (const mob of allMobs) {
if (mob.hostile) hostileCount++
else passiveCount++
}
// Spawn hostile mobs
if (hostileCount < MOB_CAP_HOSTILE) {
this.attemptHostileSpawn(playerPos, getBlock)
}
// Spawn passive mobs (less frequently)
if (passiveCount < MOB_CAP_PASSIVE && this.spawnTimer % 100 === 0) {
this.attemptPassiveSpawn(playerPos, getBlock)
}
}
private attemptHostileSpawn(
playerPos: [number, number, number],
getBlock: (x: number, y: number, z: number) => number
): void {
// Pick a random position around the player
const angle = Math.random() * Math.PI * 2
const dist = SPAWN_RADIUS_MIN + Math.random() * (SPAWN_RADIUS_MAX - SPAWN_RADIUS_MIN) * 0.5
const spawnX = playerPos[0] + Math.cos(angle) * dist
const spawnZ = playerPos[2] + Math.sin(angle) * dist
// Find a valid spawn Y
const spawnY = this.findSpawnY(spawnX, spawnZ, getBlock)
if (spawnY === null) return
// Determine which hostile mob types can spawn in this dimension
const hostileTypes: MobType[] = []
switch (this.currentDimension) {
case 'overworld':
hostileTypes.push('zombie', 'skeleton', 'creeper', 'spider')
break
case 'nether':
hostileTypes.push('zombie', 'skeleton', 'creeper', 'spider')
break
case 'end':
hostileTypes.push('enderman')
break
}
if (hostileTypes.length === 0) return
// Check if the spawn area is "dark" (simplified: always allow in nether/end, random chance in overworld)
if (this.currentDimension === 'overworld') {
// Simplified darkness check: 70% chance to allow (simulates dark areas)
if (Math.random() > 0.7) return
}
const type = hostileTypes[Math.floor(Math.random() * hostileTypes.length)]
this.spawnMob(type, [spawnX, spawnY, spawnZ])
}
private attemptPassiveSpawn(
playerPos: [number, number, number],
getBlock: (x: number, y: number, z: number) => number
): void {
// Only spawn passive mobs in overworld
if (this.currentDimension !== 'overworld') return
const angle = Math.random() * Math.PI * 2
const dist = SPAWN_RADIUS_MIN + Math.random() * 20
const spawnX = playerPos[0] + Math.cos(angle) * dist
const spawnZ = playerPos[2] + Math.sin(angle) * dist
const spawnY = this.findSpawnY(spawnX, spawnZ, getBlock)
if (spawnY === null) return
// Check if surface block is grass
const surfaceBlock = getBlock(Math.floor(spawnX), Math.floor(spawnY) - 1, Math.floor(spawnZ))
if (surfaceBlock !== BLOCK_GRASS_BLOCK) return
const passiveTypes: MobType[] = ['pig', 'cow', 'sheep', 'chicken']
const type = passiveTypes[Math.floor(Math.random() * passiveTypes.length)]
this.spawnMob(type, [spawnX, spawnY, spawnZ])
}
private findSpawnY(
x: number, z: number,
getBlock: (x: number, y: number, z: number) => number
): number | null {
const bx = Math.floor(x)
const bz = Math.floor(z)
// Search from top down for a solid block with 2 air blocks above
for (let y = 319; y >= -64; y--) {
const block = getBlock(bx, y, bz)
const above = getBlock(bx, y + 1, bz)
const above2 = getBlock(bx, y + 2, bz)
if (isSolidBlock(block) && above === BLOCK_AIR && above2 === BLOCK_AIR) {
return y + 1
}
}
return null
}
private getBlockAt(
x: number, y: number, z: number,
getBlock: (x: number, y: number, z: number) => number
): number {
return getBlock(Math.floor(x), Math.floor(y), Math.floor(z))
}
// ─── Serialization ─────────────────────────────────────────
getAllMobs(): Mob[] {
return Array.from(this.mobs.values())
}
getMobCount(): { hostile: number; passive: number } {
let hostile = 0
let passive = 0
const allMobs = Array.from(this.mobs.values())
for (const mob of allMobs) {
if (mob.hostile) hostile++
else passive++
}
return { hostile, passive }
}
}