Files
zombie-game/app/Game/GameEngine.php
T
2026-09-07 20:37:40 +02:00

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');
}
}