320 lines
10 KiB
PHP
320 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Game;
|
|
|
|
use App\Game\Entities\Boss;
|
|
use App\Game\Entities\Bullet;
|
|
use App\Game\Entities\Pickup;
|
|
use App\Game\Entities\Witch;
|
|
use App\Game\Entities\WitchProjectile;
|
|
use App\Game\Entities\Zombie;
|
|
|
|
final class GameEngine
|
|
{
|
|
private const PICKUP_INTERVAL = 10.0;
|
|
|
|
private const MAX_ZOMBIES = 30;
|
|
|
|
public function __construct(
|
|
private readonly Room $room,
|
|
private readonly Spawner $spawner,
|
|
) {}
|
|
|
|
public function tick(float $dt): void
|
|
{
|
|
if ($this->room->state !== Room::STATE_PLAYING) {
|
|
return;
|
|
}
|
|
|
|
$this->room->elapsed += $dt;
|
|
$this->room->bossCooldown = max(0.0, $this->room->bossCooldown - $dt);
|
|
|
|
$this->assignTargets();
|
|
$this->updateEntities($dt);
|
|
$this->updateSpawns($dt);
|
|
$this->updatePickupTidy($dt);
|
|
$this->resolveBulletHits();
|
|
$this->resolveProjectileHits();
|
|
$this->resolvePlayerContact();
|
|
$this->resolvePickups();
|
|
}
|
|
|
|
private function assignTargets(): void
|
|
{
|
|
$alive = $this->room->alivePlayers();
|
|
if ($alive === []) {
|
|
return;
|
|
}
|
|
foreach ($this->room->entities as $entity) {
|
|
if (! $entity instanceof Zombie) {
|
|
continue;
|
|
}
|
|
$entity->target = $this->nearestPlayer($entity, $alive);
|
|
}
|
|
}
|
|
|
|
/** @param array<int, Player> $alive */
|
|
private function nearestPlayer(Zombie $zombie, array $alive): ?Player
|
|
{
|
|
$best = null;
|
|
$bestDist = INF;
|
|
foreach ($alive as $player) {
|
|
$dist = $player->pos->subV($zombie->pos)->lengthSquared();
|
|
if ($dist < $bestDist) {
|
|
$bestDist = $dist;
|
|
$best = $player;
|
|
}
|
|
}
|
|
|
|
return $best;
|
|
}
|
|
|
|
private function updateEntities(float $dt): void
|
|
{
|
|
// Players first so bullets spawn before resolution this frame.
|
|
foreach ($this->room->players as $player) {
|
|
if (! $player->dead) {
|
|
$player->update($dt);
|
|
}
|
|
}
|
|
|
|
foreach ($this->room->entities->copy() as $entity) {
|
|
if ($entity instanceof Player) {
|
|
continue;
|
|
}
|
|
$entity->update($dt);
|
|
}
|
|
}
|
|
|
|
private function updateSpawns(float $dt): void
|
|
{
|
|
$this->room->spawnTimer += $dt;
|
|
$interval = $this->spawner->spawnInterval($this->room->elapsed);
|
|
if ($this->room->spawnTimer >= $interval) {
|
|
$this->room->spawnTimer = 0.0;
|
|
$zombieCount = 0;
|
|
foreach ($this->room->entities as $entity) {
|
|
if ($entity instanceof Zombie) {
|
|
$zombieCount++;
|
|
}
|
|
}
|
|
if ($zombieCount < self::MAX_ZOMBIES) {
|
|
$this->spawner->spawnZombie($this->room);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function updatePickupTidy(float $dt): void
|
|
{
|
|
foreach ($this->room->entities->copy() as $pickup) {
|
|
if (! $pickup instanceof Pickup) {
|
|
continue;
|
|
}
|
|
$anyNeeds = false;
|
|
foreach ($this->room->alivePlayers() as $player) {
|
|
if (($player->upgrades[$pickup->type] ?? 0) < $player->maxUpgradeLevel) {
|
|
$anyNeeds = true;
|
|
break;
|
|
}
|
|
}
|
|
if (! $anyNeeds) {
|
|
$pickup->removeFrom($this->room->entities);
|
|
}
|
|
}
|
|
|
|
$this->room->pickupTimer -= $dt;
|
|
if ($this->room->pickupTimer <= 0.0) {
|
|
$this->room->pickupTimer = self::PICKUP_INTERVAL;
|
|
if ($this->room->alivePlayers() !== []) {
|
|
$this->spawner->spawnPickup($this->room);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function resolveBulletHits(): void
|
|
{
|
|
foreach ($this->room->entities->copy() as $bullet) {
|
|
if (! $bullet instanceof Bullet) {
|
|
continue;
|
|
}
|
|
foreach ($this->room->entities->copy() as $zombie) {
|
|
if (! $zombie instanceof Zombie || ! $bullet->intersects($zombie)) {
|
|
continue;
|
|
}
|
|
$impulse = $zombie->pos->subV($bullet->pos);
|
|
$impulse = $impulse->lengthSquared() > 1.0
|
|
? $impulse->divS($impulse->length())
|
|
: new Vector2(1.0, 0.0);
|
|
$bullet->removeFrom($this->room->entities);
|
|
|
|
$this->room->pushEvent('bullethit', [
|
|
'x' => $bullet->pos->x,
|
|
'y' => $bullet->pos->y,
|
|
'color' => array_slice($zombie->baseColor, 0, 3),
|
|
]);
|
|
|
|
if (! $zombie->hit($impulse, $bullet->damage)) {
|
|
break;
|
|
}
|
|
$zombie->die();
|
|
|
|
$owner = $bullet->owner;
|
|
if (! $owner->dead) {
|
|
$owner->addScore($zombie->scoreValue);
|
|
}
|
|
|
|
$killColor = array_slice($zombie->baseColor, 0, 3);
|
|
$this->room->pushEvent('kill', [
|
|
'x' => $zombie->pos->x,
|
|
'y' => $zombie->pos->y,
|
|
'color' => $killColor,
|
|
'boss' => $zombie instanceof Boss,
|
|
'big' => $zombie->maxHealth > 1,
|
|
]);
|
|
|
|
if ($zombie instanceof Boss) {
|
|
$this->room->nextBossScore *= 2.0;
|
|
$this->room->bossCooldown = 60.0;
|
|
$this->room->pushEvent('bossdefeated', [
|
|
'x' => $zombie->pos->x,
|
|
'y' => $zombie->pos->y,
|
|
]);
|
|
} elseif (
|
|
$owner->score >= $this->room->nextBossScore
|
|
&& $this->room->bossCooldown <= 0.0
|
|
&& ! $this->room->hasBoss()
|
|
) {
|
|
$this->spawner->spawnBoss($this->room);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function resolveProjectileHits(): void
|
|
{
|
|
foreach ($this->room->entities->copy() as $projectile) {
|
|
if (! $projectile instanceof WitchProjectile) {
|
|
continue;
|
|
}
|
|
foreach ($this->room->alivePlayers() as $player) {
|
|
if (! $projectile->intersects($player)) {
|
|
continue;
|
|
}
|
|
$projectile->removeFrom($this->room->entities);
|
|
$player->takeDamage();
|
|
$this->room->pushEvent('playerdamage', [
|
|
'x' => $projectile->pos->x,
|
|
'y' => $projectile->pos->y,
|
|
'id' => $player->id,
|
|
]);
|
|
if ($player->dead) {
|
|
$this->room->pushEvent('playerdeath', [
|
|
'x' => $player->pos->x,
|
|
'y' => $player->pos->y,
|
|
'id' => $player->id,
|
|
]);
|
|
$player->removeFrom($this->room->entities);
|
|
$this->checkGameOver();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function resolvePlayerContact(): void
|
|
{
|
|
foreach ($this->room->entities->copy() as $zombie) {
|
|
if (! $zombie instanceof Zombie) {
|
|
continue;
|
|
}
|
|
foreach ($this->room->alivePlayers() as $player) {
|
|
if (! $zombie->intersects($player)) {
|
|
continue;
|
|
}
|
|
if (! $zombie instanceof Boss && ! $zombie instanceof Witch) {
|
|
$zombie->removeFrom($this->room->entities);
|
|
$this->room->pushEvent('bloodpool', [
|
|
'x' => $zombie->pos->x,
|
|
'y' => $zombie->pos->y,
|
|
's' => $zombie->size,
|
|
]);
|
|
}
|
|
$player->takeDamage();
|
|
$this->room->pushEvent('playerdamage', [
|
|
'x' => $player->pos->x,
|
|
'y' => $player->pos->y,
|
|
'id' => $player->id,
|
|
]);
|
|
if ($player->dead) {
|
|
$this->room->pushEvent('playerdeath', [
|
|
'x' => $player->pos->x,
|
|
'y' => $player->pos->y,
|
|
'id' => $player->id,
|
|
]);
|
|
$player->removeFrom($this->room->entities);
|
|
$this->checkGameOver();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function resolvePickups(): void
|
|
{
|
|
foreach ($this->room->entities->copy() as $pickup) {
|
|
if (! $pickup instanceof Pickup) {
|
|
continue;
|
|
}
|
|
foreach ($this->room->alivePlayers() as $player) {
|
|
if (! $this->pickupIntersects($pickup, $player)) {
|
|
continue;
|
|
}
|
|
$maxed = ($player->upgrades[$pickup->type] ?? 0) >= $player->maxUpgradeLevel;
|
|
if ($maxed) {
|
|
$this->room->pushEvent('pickupmax', [
|
|
'x' => $player->pos->x,
|
|
'y' => $player->pos->y,
|
|
'type' => $pickup->type,
|
|
'id' => $player->id,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
$pickup->removeFrom($this->room->entities);
|
|
$player->applyUpgrade($pickup->type);
|
|
$this->room->pushEvent('pickup', [
|
|
'x' => $pickup->pos->x,
|
|
'y' => $pickup->pos->y,
|
|
'type' => $pickup->type,
|
|
'id' => $player->id,
|
|
]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function pickupIntersects(Pickup $pickup, Player $player): bool
|
|
{
|
|
$dx = $pickup->pos->x - $player->pos->x;
|
|
$dy = $pickup->pos->y - $player->pos->y;
|
|
|
|
return $dx * $dx + $dy * $dy <= $pickup->collectRadius * $pickup->collectRadius
|
|
|| $pickup->intersects($player);
|
|
}
|
|
|
|
private function checkGameOver(): void
|
|
{
|
|
if ($this->room->alivePlayers() !== []) {
|
|
return;
|
|
}
|
|
$this->room->state = Room::STATE_OVER;
|
|
foreach ($this->room->entities->copy() as $entity) {
|
|
if ($entity instanceof Zombie) {
|
|
$entity->removeFrom($this->room->entities);
|
|
}
|
|
}
|
|
$this->room->pushEvent('gameover');
|
|
}
|
|
}
|