/** * 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 = { 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 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 } } }