Spaces:
Sleeping
Sleeping
| /** | |
| * 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 } | |
| } | |
| } | |