diff --git a/game/sim/enemies.py b/game/sim/enemies.py new file mode 100644 index 0000000..e60f9ea --- /dev/null +++ b/game/sim/enemies.py @@ -0,0 +1,269 @@ +import math + +from .. import constants +from ..constants import tileSize +from .entity import Actor +from . import physics +from .stateMachine import State, StateMachine +from .projectile import Projectile + + +class Enemy(Actor): + stompable = True + hasGravity = True + contactDamage = constants.contactDamage + kind = "enemy" + + def __init__(self, x, y, w, h): + super().__init__(x, y, w, h) + self.hp = 1 + self.direction = -1 + self.machine = StateMachine(self) + self.spawnX = float(x) + self.spawnY = float(y) + + def initialState(self): + return None + + def stepGround(self, world, speed): + self.vx = speed * self.direction + self.vy += constants.gravity + if self.vy > constants.maxFallSpeed: + self.vy = constants.maxFallSpeed + physics.moveHorizontal(self, world.level, world.platforms) + if (self.direction > 0 and self.onWallRight) or (self.direction < 0 and self.onWallLeft): + self.direction *= -1 + self.vx = 0.0 + wasOnGround = self.onGround + physics.moveVertical(self, world.level, world.platforms) + physics.resolveSlope(self, world.level, wasOnGround) + self.facing = self.direction + if self.onGround: + aheadX = self.x + (self.w + 1 if self.direction > 0 else -1) + col = int(aheadX // tileSize) + row = int((self.y + self.h + 2) // tileSize) + if not world.level.solidAt(col, row) and not world.level.oneWayAt(col, row) and not world.level.slopeAt(col, row): + self.direction *= -1 + + def playerDistance(self, world): + return abs(world.player.centerX - self.centerX) + + def playerVerticalDistance(self, world): + return abs(world.player.centerY - self.centerY) + + def facePlayer(self, world): + self.direction = 1 if world.player.centerX >= self.centerX else -1 + + def update(self, world): + if self.machine.current is None: + self.machine.setInitial(self.initialState(), world) + self.machine.update(world) + + def onStomped(self, world): + self.hp -= 1 + if self.hp <= 0: + self.alive = False + world.spawnBurst("stomp", self.centerX, self.centerY, 6) + + +class PatrolState(State): + name = "patrol" + + def update(self, owner, world): + owner.stepGround(world, constants.enemySpeed) + return None + + +class Goomba(Enemy): + kind = "goomba" + + def initialState(self): + return "patrol" + + def __init__(self, x, y): + super().__init__(x, y, 16, 16) + self.machine.addState(PatrolState()) + + +class ChaserPatrol(State): + name = "patrol" + detectRange = 90.0 + detectHeight = 40.0 + + def update(self, owner, world): + owner.stepGround(world, constants.enemySpeed) + if owner.playerDistance(world) < self.detectRange and owner.playerVerticalDistance(world) < self.detectHeight: + return "chase" + return None + + +class ChaseState(State): + name = "chase" + loseRange = 160.0 + + def onEnter(self, owner, world): + world.spawnBurst("alert", owner.centerX, owner.top - 4, 3) + + def update(self, owner, world): + owner.facePlayer(world) + owner.stepGround(world, constants.chaserSpeed) + if owner.playerDistance(world) > self.loseRange: + return "patrol" + return None + + +class Chaser(Enemy): + kind = "chaser" + + def __init__(self, x, y): + super().__init__(x, y, 16, 16) + self.hp = 2 + self.machine.addState(ChaserPatrol()).addState(ChaseState()) + + def initialState(self): + return "patrol" + + +class FlyerHover(State): + name = "hover" + detectRange = 120.0 + + def update(self, owner, world): + owner.hoverPhase += 0.08 + owner.x += owner.direction * constants.flyerSpeed + owner.y = owner.baseY + math.sin(owner.hoverPhase) * 14.0 + box = owner.aabb + blockedRight = owner.direction > 0 and physics.touchingSolidSide(owner, world.level, world.platforms, 1) + blockedLeft = owner.direction < 0 and physics.touchingSolidSide(owner, world.level, world.platforms, -1) + if blockedRight or blockedLeft or owner.x < owner.patrolMin or owner.x > owner.patrolMax: + owner.direction *= -1 + owner.x = min(max(owner.x, owner.patrolMin), owner.patrolMax) + owner.facing = owner.direction + if owner.playerDistance(world) < self.detectRange: + return "pursue" + return None + + +class FlyerPursue(State): + name = "pursue" + loseRange = 190.0 + + def update(self, owner, world): + target = world.player + dx = target.centerX - owner.centerX + dy = target.centerY - owner.centerY + distance = math.hypot(dx, dy) or 1.0 + owner.x += (dx / distance) * constants.flyerSpeed * 1.4 + owner.y += (dy / distance) * constants.flyerSpeed * 1.4 + owner.baseY = owner.y + owner.facing = 1 if dx >= 0 else -1 + owner.direction = owner.facing + if owner.playerDistance(world) > self.loseRange: + return "hover" + return None + + +class Flyer(Enemy): + kind = "flyer" + hasGravity = False + stompable = True + + def __init__(self, x, y): + super().__init__(x, y, 16, 14) + self.baseY = float(y) + self.hoverPhase = 0.0 + self.patrolMin = float(x) - 48.0 + self.patrolMax = float(x) + 48.0 + self.machine.addState(FlyerHover()).addState(FlyerPursue()) + + def initialState(self): + return "hover" + + +class ShooterIdle(State): + name = "idle" + detectRange = 150.0 + + def update(self, owner, world): + if owner.cooldown > 0: + owner.cooldown -= 1 + if owner.playerDistance(world) < self.detectRange and owner.cooldown == 0: + owner.facePlayer(world) + return "aim" + return None + + +class ShooterAim(State): + name = "aim" + windup = 18 + + def update(self, owner, world): + owner.facePlayer(world) + if self.timeGuard(owner): + return "fire" + return None + + def timeGuard(self, owner): + return owner.machine.timeInState >= self.windup + + +class ShooterFire(State): + name = "fire" + + def onEnter(self, owner, world): + target = world.player + dx = target.centerX - owner.centerX + dy = target.centerY - owner.centerY + distance = math.hypot(dx, dy) or 1.0 + speed = constants.projectileSpeed + projectile = Projectile( + owner.centerX - 3, + owner.centerY - 3, + (dx / distance) * speed, + (dy / distance) * speed, + owner="enemy", + damage=1, + ) + world.spawnProjectile(projectile) + world.playSound("shoot", owner.centerX) + owner.cooldown = constants.shooterCooldown + + def update(self, owner, world): + return "idle" + + +class Shooter(Enemy): + kind = "shooter" + hasGravity = True + stompable = True + + def __init__(self, x, y): + super().__init__(x, y, 16, 16) + self.hp = 2 + self.cooldown = 0 + self.machine.addState(ShooterIdle()).addState(ShooterAim()).addState(ShooterFire()) + + def initialState(self): + return "idle" + + def update(self, world): + if self.hasGravity: + self.vx = 0.0 + self.vy += constants.gravity + if self.vy > constants.maxFallSpeed: + self.vy = constants.maxFallSpeed + physics.moveVertical(self, world.level, world.platforms) + super().update(world) + + +enemyFactory = { + "goomba": Goomba, + "chaser": Chaser, + "flyer": Flyer, + "shooter": Shooter, +} + + +def spawnEnemy(spawn): + factory = enemyFactory.get(spawn.kind, Goomba) + return factory(spawn.x, spawn.y)