initial commit
This commit is contained in:
commit
e8c3f9862a
193 files changed
+29795
No files matched your search
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Entities;
|
||||
|
||||
use App\Game\EntityList;
|
||||
use App\Game\Player;
|
||||
use App\Game\Room;
|
||||
|
||||
final class Boss extends Zombie
|
||||
{
|
||||
private float $spawnInterval;
|
||||
|
||||
private int $spawnCount;
|
||||
|
||||
private float $spawnTimer;
|
||||
|
||||
/** @var null|callable(float,float,EntityList,Room,?Player):void */
|
||||
private $spawnZombie;
|
||||
|
||||
public function __construct(
|
||||
float $x,
|
||||
float $y,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
?Player $initialTarget,
|
||||
array $config = [],
|
||||
?callable $onDeath = null,
|
||||
) {
|
||||
parent::__construct($x, $y, $container, $room, $config, $onDeath);
|
||||
if ($initialTarget !== null) {
|
||||
$this->target = $initialTarget;
|
||||
}
|
||||
$this->spawnInterval = $config['spawnInterval'] ?? 5.0;
|
||||
$this->spawnCount = $config['spawnCount'] ?? 1;
|
||||
$this->spawnTimer = 3.0;
|
||||
$this->spawnZombie = $config['spawnZombie'] ?? null;
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'boss';
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
parent::update($dt);
|
||||
|
||||
$this->spawnTimer -= $dt;
|
||||
if ($this->spawnTimer > 0.0 || $this->spawnZombie === null) {
|
||||
return;
|
||||
}
|
||||
$this->spawnTimer = $this->spawnInterval;
|
||||
|
||||
$minions = 0;
|
||||
foreach ($this->container as $obj) {
|
||||
if ($obj instanceof Zombie && ! ($obj instanceof self)) {
|
||||
$minions++;
|
||||
}
|
||||
}
|
||||
if ($minions >= 15) {
|
||||
return;
|
||||
}
|
||||
for ($i = 0; $i < $this->spawnCount; $i++) {
|
||||
$angle = ($i / $this->spawnCount) * M_PI * 2.0 + $this->rand01() * 0.6;
|
||||
($this->spawnZombie)(
|
||||
$this->pos->x + cos($angle) * 70.0,
|
||||
$this->pos->y + sin($angle) * 70.0,
|
||||
$this->container,
|
||||
$this->room,
|
||||
$this->target,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function toSnapshot(): array
|
||||
{
|
||||
$data = parent::toSnapshot();
|
||||
$data['type'] = 'boss';
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Entities;
|
||||
|
||||
use App\Game\EntityList;
|
||||
use App\Game\GameObject;
|
||||
use App\Game\Player;
|
||||
use App\Game\Room;
|
||||
use App\Game\Vector2;
|
||||
|
||||
final class Bullet extends GameObject
|
||||
{
|
||||
private EntityList $container;
|
||||
|
||||
private Room $room;
|
||||
|
||||
public float $size;
|
||||
|
||||
public int $damage;
|
||||
|
||||
/** @var array<float> */
|
||||
public array $color;
|
||||
|
||||
public Player $owner;
|
||||
|
||||
public function __construct(
|
||||
float $x,
|
||||
float $y,
|
||||
Vector2 $direction,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
Player $owner,
|
||||
float $speed = 800.0,
|
||||
float $size = 8.0,
|
||||
int $damage = 1,
|
||||
) {
|
||||
parent::__construct($x, $y);
|
||||
$this->container = $container;
|
||||
$this->room = $room;
|
||||
$this->owner = $owner;
|
||||
$this->velocity = $direction->mulS($speed);
|
||||
$this->size = $size;
|
||||
$this->damage = $damage;
|
||||
$this->color = [1.0, 0.2, 0.2, 1.0];
|
||||
$this->width = $size;
|
||||
$this->height = $size;
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'bullet';
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
parent::update($dt);
|
||||
|
||||
if (
|
||||
$this->pos->x < -$this->size
|
||||
|| $this->pos->x > $this->room->width + $this->size
|
||||
|| $this->pos->y < -$this->size
|
||||
|| $this->pos->y > $this->room->height + $this->size
|
||||
) {
|
||||
$this->container->remove($this);
|
||||
}
|
||||
}
|
||||
|
||||
public function toSnapshot(): array
|
||||
{
|
||||
return [
|
||||
'eid' => $this->eid,
|
||||
'd' => $this->kind(),
|
||||
'x' => round($this->pos->x, 1),
|
||||
'y' => round($this->pos->y, 1),
|
||||
'vx' => round($this->velocity->x, 1),
|
||||
'vy' => round($this->velocity->y, 1),
|
||||
's' => $this->size,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Entities;
|
||||
|
||||
use App\Game\EntityList;
|
||||
use App\Game\Player;
|
||||
use App\Game\Room;
|
||||
use App\Game\Vector2;
|
||||
|
||||
final class Leaper extends Zombie
|
||||
{
|
||||
private float $dashRange;
|
||||
|
||||
private float $windupTime;
|
||||
|
||||
private float $dashTime;
|
||||
|
||||
private float $dashSpeed;
|
||||
|
||||
private float $dashCooldown;
|
||||
|
||||
private float $dashTimer = 0.0;
|
||||
|
||||
private float $windupTimer = 0.0;
|
||||
|
||||
private float $dashCooldownTimer = 0.0;
|
||||
|
||||
private Vector2 $dashDirection;
|
||||
|
||||
public function __construct(
|
||||
float $x,
|
||||
float $y,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
?Player $initialTarget,
|
||||
array $config = [],
|
||||
?callable $onDeath = null,
|
||||
) {
|
||||
parent::__construct($x, $y, $container, $room, $config, $onDeath);
|
||||
if ($initialTarget !== null) {
|
||||
$this->target = $initialTarget;
|
||||
}
|
||||
$this->dashRange = $config['dashRange'] ?? 300.0;
|
||||
$this->windupTime = $config['windupTime'] ?? 0.35;
|
||||
$this->dashTime = $config['dashTime'] ?? 0.28;
|
||||
$this->dashSpeed = $config['dashSpeed'] ?? 680.0;
|
||||
$this->dashCooldown = $config['dashCooldown'] ?? 1.8;
|
||||
$this->dashDirection = new Vector2(1.0, 0.0);
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'leaper';
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
$this->time += $dt;
|
||||
if ($this->updateEmerge($dt)) {
|
||||
return;
|
||||
}
|
||||
$this->dashCooldownTimer = max(0.0, $this->dashCooldownTimer - $dt);
|
||||
|
||||
$target = $this->target;
|
||||
$delta = $target !== null ? $target->pos->subV($this->pos) : Vector2::zero();
|
||||
$distance = $delta->length();
|
||||
|
||||
$move = Vector2::zero();
|
||||
if ($this->windupTimer > 0.0) {
|
||||
$this->windupTimer -= $dt;
|
||||
if ($this->windupTimer <= 0.0) {
|
||||
$this->dashTimer = $this->dashTime;
|
||||
$this->dashDirection = $distance > 1.0 ? $delta->divS($distance) : new Vector2(1.0, 0.0);
|
||||
}
|
||||
} elseif ($this->dashTimer > 0.0) {
|
||||
$this->dashTimer -= $dt;
|
||||
$move = $this->dashDirection->mulS($this->dashSpeed);
|
||||
} elseif ($distance > 1.0) {
|
||||
if ($distance < $this->dashRange && $this->dashCooldownTimer <= 0.0) {
|
||||
$this->windupTimer = $this->windupTime;
|
||||
} else {
|
||||
$move = $delta->divS($distance)->mulS($this->speed);
|
||||
}
|
||||
}
|
||||
|
||||
$this->velocity = $move->addV($this->hitKnockback);
|
||||
$this->move($dt);
|
||||
|
||||
$this->hitKnockback = $this->hitKnockback->mulS(max(0.0, 1 - $dt * $this->knockbackDamping));
|
||||
$this->flash = max(0.0, $this->flash - $dt);
|
||||
}
|
||||
|
||||
public function toSnapshot(): array
|
||||
{
|
||||
$data = parent::toSnapshot();
|
||||
$data['dash'] = $this->dashTimer > 0.0;
|
||||
$data['wind'] = $this->windupTimer > 0.0;
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Entities;
|
||||
|
||||
use App\Game\EntityList;
|
||||
use App\Game\Player;
|
||||
use App\Game\Room;
|
||||
|
||||
final class MegaTank extends Zombie
|
||||
{
|
||||
public function __construct(
|
||||
float $x,
|
||||
float $y,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
?Player $initialTarget,
|
||||
array $config = [],
|
||||
?callable $onDeath = null,
|
||||
) {
|
||||
$spawnCount = $config['spawnCount'] ?? 3;
|
||||
$spawnOnDeath = $config['spawnOnDeath'] ?? null;
|
||||
parent::__construct($x, $y, $container, $room, $config, $onDeath ?? function (Zombie $mega) use ($spawnCount, $spawnOnDeath): void {
|
||||
if ($spawnOnDeath === null) {
|
||||
return;
|
||||
}
|
||||
for ($i = 0; $i < $spawnCount; $i++) {
|
||||
$angle = ($i / $spawnCount) * M_PI * 2.0;
|
||||
$offset = $mega->size + 28.0;
|
||||
$spawnOnDeath(
|
||||
$mega->pos->x + cos($angle) * $offset,
|
||||
$mega->pos->y + sin($angle) * $offset,
|
||||
$mega->container,
|
||||
$mega->room,
|
||||
$mega->target,
|
||||
);
|
||||
}
|
||||
});
|
||||
if ($initialTarget !== null) {
|
||||
$this->target = $initialTarget;
|
||||
}
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'megatank';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Entities;
|
||||
|
||||
use App\Game\EntityList;
|
||||
use App\Game\Player;
|
||||
use App\Game\Room;
|
||||
use App\Game\Vector2;
|
||||
|
||||
final class MegaWitch extends Witch
|
||||
{
|
||||
private float $novaInterval;
|
||||
|
||||
private int $novaCount;
|
||||
|
||||
private float $novaProjectileSpeed;
|
||||
|
||||
private float $novaTimer;
|
||||
|
||||
private float $novaRotation;
|
||||
|
||||
/** @var array<int, array{0:float, 1:\Closure}> */
|
||||
private array $pendingNova = [];
|
||||
|
||||
public function __construct(
|
||||
float $x,
|
||||
float $y,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
?Player $initialTarget,
|
||||
array $config = [],
|
||||
?callable $onDeath = null,
|
||||
) {
|
||||
$spawnCount = $config['spawnCount'] ?? 3;
|
||||
$spawnOnDeath = $config['spawnOnDeath'] ?? null;
|
||||
parent::__construct($x, $y, $container, $room, $initialTarget, $config, $onDeath ?? function (Zombie $mega) use ($spawnCount, $spawnOnDeath): void {
|
||||
if ($spawnOnDeath === null) {
|
||||
return;
|
||||
}
|
||||
for ($i = 0; $i < $spawnCount; $i++) {
|
||||
$angle = ($i / $spawnCount) * M_PI * 2.0;
|
||||
$offset = $mega->size + 30.0;
|
||||
$spawnOnDeath(
|
||||
$mega->pos->x + cos($angle) * $offset,
|
||||
$mega->pos->y + sin($angle) * $offset,
|
||||
$mega->container,
|
||||
$mega->room,
|
||||
$mega->target,
|
||||
);
|
||||
}
|
||||
});
|
||||
if ($initialTarget !== null) {
|
||||
$this->target = $initialTarget;
|
||||
}
|
||||
$this->novaInterval = $config['novaInterval'] ?? 5.0;
|
||||
$this->novaCount = $config['novaCount'] ?? 16;
|
||||
$this->novaProjectileSpeed = $config['novaProjectileSpeed'] ?? 250.0;
|
||||
$this->novaTimer = $this->novaInterval * 0.5;
|
||||
$this->novaRotation = $config['novaRotation'] ?? $this->rand01() * M_PI * 2.0;
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'megawitch';
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
parent::update($dt);
|
||||
|
||||
$target = $this->target;
|
||||
if ($target !== null && $target->dead) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_keys($this->pendingNova) as $i) {
|
||||
$this->pendingNova[$i][0] -= $dt;
|
||||
if ($this->pendingNova[$i][0] <= 0.0) {
|
||||
$closure = $this->pendingNova[$i][1];
|
||||
unset($this->pendingNova[$i]);
|
||||
$closure();
|
||||
}
|
||||
}
|
||||
|
||||
$this->novaTimer -= $dt;
|
||||
if ($this->novaTimer <= 0.0) {
|
||||
$this->novaTimer = $this->novaInterval * (0.85 + $this->rand01() * 0.3);
|
||||
$this->nova();
|
||||
}
|
||||
}
|
||||
|
||||
private function nova(): void
|
||||
{
|
||||
$lost = 1.0 - max(0.0, $this->health / max(1, $this->maxHealth));
|
||||
$count = max(6, (int) round($this->novaCount * (0.75 + 0.25 * $lost)));
|
||||
$speed = $this->novaProjectileSpeed * (1.0 + $lost * 0.45);
|
||||
$size = max(8.0, $this->size * 0.3);
|
||||
$color = [1.0, 0.5, 0.3, 1.0];
|
||||
$this->novaRotation += 0.36;
|
||||
|
||||
$fireRing = function (float $offset) use ($count, $speed, $size, $color): void {
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$angle = ($i / $count) * M_PI * 2.0 + $this->novaRotation + $offset;
|
||||
$direction = new Vector2(cos($angle), sin($angle));
|
||||
$this->container->append(new WitchProjectile(
|
||||
$this->pos->x,
|
||||
$this->pos->y,
|
||||
$direction,
|
||||
$this->container,
|
||||
$this->room,
|
||||
$speed,
|
||||
$size,
|
||||
$color,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
$fireRing(0.0);
|
||||
$this->pendingNova[] = [0.22, function () use ($fireRing, $count): void {
|
||||
$fireRing(M_PI / $count);
|
||||
}];
|
||||
if ($lost > 0.34) {
|
||||
$this->pendingNova[] = [0.44, function () use ($fireRing): void {
|
||||
$fireRing(0.0);
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
public function toSnapshot(): array
|
||||
{
|
||||
$data = parent::toSnapshot();
|
||||
$data['charge'] = 1.0 - max(0.0, $this->novaTimer) / $this->novaInterval;
|
||||
|
||||
if ($data['hp'] < $data['mhp'] && $data['charge'] > 0.72) {
|
||||
$data['nova'] = true;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Entities;
|
||||
|
||||
use App\Game\EntityList;
|
||||
use App\Game\GameObject;
|
||||
use App\Game\Player;
|
||||
use App\Game\Room;
|
||||
|
||||
final class Pickup extends GameObject
|
||||
{
|
||||
public const TYPES = [
|
||||
'rapid' => 'Rapid Fire',
|
||||
'damage' => 'Damage +1',
|
||||
'multishot' => 'Multishot',
|
||||
'speed' => 'Bullet Speed',
|
||||
];
|
||||
|
||||
private EntityList $container;
|
||||
|
||||
private Room $room;
|
||||
|
||||
public string $type;
|
||||
|
||||
public float $life;
|
||||
|
||||
private float $time = 0.0;
|
||||
|
||||
public float $collectRadius;
|
||||
|
||||
public float $size;
|
||||
|
||||
/** @var array<float> */
|
||||
public array $color;
|
||||
|
||||
public function __construct(
|
||||
float $x,
|
||||
float $y,
|
||||
string $type,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
float $life = 10.0,
|
||||
float $collectRadius = 46.0,
|
||||
) {
|
||||
parent::__construct($x, $y);
|
||||
$this->container = $container;
|
||||
$this->room = $room;
|
||||
$this->type = $type;
|
||||
$this->life = $life;
|
||||
$this->collectRadius = $collectRadius;
|
||||
$this->time = 0.0;
|
||||
$this->size = 16.0;
|
||||
$this->width = $this->size;
|
||||
$this->height = $this->size;
|
||||
$this->color = self::colorFor($type);
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'pickup';
|
||||
}
|
||||
|
||||
public static function colorFor(string $type): array
|
||||
{
|
||||
return match ($type) {
|
||||
'rapid' => [1.0, 0.8, 0.2, 1.0],
|
||||
'damage' => [1.0, 0.3, 0.3, 1.0],
|
||||
'multishot' => [0.2, 0.8, 1.0, 1.0],
|
||||
'speed' => [0.6, 0.9, 1.0, 1.0],
|
||||
default => [1.0, 1.0, 1.0, 1.0],
|
||||
};
|
||||
}
|
||||
|
||||
public function collectableBy(Player $player): bool
|
||||
{
|
||||
if ($player instanceof Player && ! $player->dead) {
|
||||
return $player->maxUpgradeLevel > ($player->upgrades[$this->type] ?? 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
$this->time += $dt;
|
||||
if ($this->time >= $this->life) {
|
||||
$this->container->remove($this);
|
||||
}
|
||||
}
|
||||
|
||||
public function remaining(): float
|
||||
{
|
||||
return max(0.0, $this->life - $this->time);
|
||||
}
|
||||
|
||||
public function toSnapshot(): array
|
||||
{
|
||||
return [
|
||||
'eid' => $this->eid,
|
||||
'd' => $this->kind(),
|
||||
'x' => round($this->pos->x, 1),
|
||||
'y' => round($this->pos->y, 1),
|
||||
't' => $this->type,
|
||||
'life' => round($this->remaining(), 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Entities;
|
||||
|
||||
use App\Game\EntityList;
|
||||
use App\Game\Player;
|
||||
use App\Game\Room;
|
||||
use App\Game\Vector2;
|
||||
|
||||
class Witch extends Zombie
|
||||
{
|
||||
public float $preferredDistance;
|
||||
|
||||
public float $fireInterval;
|
||||
|
||||
public float $projectileSpeed;
|
||||
|
||||
public int $projectileCount;
|
||||
|
||||
public float $projectileSpread;
|
||||
|
||||
/** @var array<float> */
|
||||
public array $projectileColor;
|
||||
|
||||
protected float $fireTimer = 0.0;
|
||||
|
||||
public function __construct(
|
||||
float $x,
|
||||
float $y,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
?Player $initialTarget,
|
||||
array $config = [],
|
||||
?callable $onDeath = null,
|
||||
) {
|
||||
parent::__construct($x, $y, $container, $room, $config, $onDeath);
|
||||
if ($initialTarget !== null) {
|
||||
$this->target = $initialTarget;
|
||||
}
|
||||
$this->preferredDistance = $config['preferredDistance'] ?? 220.0;
|
||||
$this->fireInterval = $config['fireInterval'] ?? 2.2;
|
||||
$this->projectileSpeed = $config['projectileSpeed'] ?? 220.0;
|
||||
$this->projectileCount = $config['projectileCount'] ?? 1;
|
||||
$this->projectileSpread = $config['projectileSpread'] ?? 0.08;
|
||||
$this->projectileColor = $config['projectileColor'] ?? [0.7, 0.2, 1.0, 1.0];
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'witch';
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
$this->time += $dt;
|
||||
|
||||
if ($this->updateEmerge($dt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->fireTimer -= $dt;
|
||||
|
||||
$target = $this->target;
|
||||
if ($target === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delta = $target->pos->subV($this->pos);
|
||||
$distance = $delta->length();
|
||||
|
||||
$move = Vector2::zero();
|
||||
if ($distance > 1.0) {
|
||||
$dir = $delta->divS($distance);
|
||||
if ($distance < $this->preferredDistance) {
|
||||
$move = $dir->mulS(-$this->speed);
|
||||
} elseif ($distance > $this->preferredDistance + 40.0) {
|
||||
$move = $dir->mulS($this->speed);
|
||||
}
|
||||
}
|
||||
|
||||
$this->velocity = $move->addV($this->hitKnockback);
|
||||
$this->move($dt);
|
||||
|
||||
$this->hitKnockback = $this->hitKnockback->mulS(max(0.0, 1 - $dt * $this->knockbackDamping));
|
||||
$this->flash = max(0.0, $this->flash - $dt);
|
||||
|
||||
if ($this->fireTimer <= 0.0 && ! $target->dead && $distance < 480.0) {
|
||||
$this->fireTimer = $this->fireInterval * (0.8 + $this->rand01() * 0.4);
|
||||
$this->fire();
|
||||
}
|
||||
}
|
||||
|
||||
protected function onEmergeComplete(): void
|
||||
{
|
||||
$this->fireTimer = $this->fireInterval * (0.4 + $this->rand01() * 0.4);
|
||||
}
|
||||
|
||||
public function fire(): void
|
||||
{
|
||||
$target = $this->target;
|
||||
if ($target === null) {
|
||||
return;
|
||||
}
|
||||
$aim = $target->pos->subV($this->pos);
|
||||
$aimAngle = $aim->lengthSquared() > 1.0 ? atan2($aim->y, $aim->x) : $this->time;
|
||||
|
||||
$offset = (($this->projectileCount - 1) / 2.0) * $this->projectileSpread;
|
||||
for ($i = 0; $i < $this->projectileCount; $i++) {
|
||||
$angle = $aimAngle - $offset + $i * $this->projectileSpread;
|
||||
$direction = new Vector2(cos($angle), sin($angle));
|
||||
$this->container->append(new WitchProjectile(
|
||||
$this->pos->x,
|
||||
$this->pos->y,
|
||||
$direction,
|
||||
$this->container,
|
||||
$this->room,
|
||||
$this->projectileSpeed,
|
||||
$this->size * 0.4,
|
||||
$this->projectileColor,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Entities;
|
||||
|
||||
use App\Game\EntityList;
|
||||
use App\Game\GameObject;
|
||||
use App\Game\Room;
|
||||
use App\Game\Vector2;
|
||||
|
||||
final class WitchProjectile extends GameObject
|
||||
{
|
||||
private EntityList $container;
|
||||
|
||||
private Room $room;
|
||||
|
||||
public float $size;
|
||||
|
||||
/** @var array<float> */
|
||||
public array $color;
|
||||
|
||||
private float $time = 0.0;
|
||||
|
||||
public function __construct(
|
||||
float $x,
|
||||
float $y,
|
||||
Vector2 $direction,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
float $speed = 220.0,
|
||||
float $size = 12.0,
|
||||
array $color = [0.7, 0.2, 1.0, 1.0],
|
||||
) {
|
||||
parent::__construct($x, $y);
|
||||
$this->container = $container;
|
||||
$this->room = $room;
|
||||
$this->velocity = $direction->mulS($speed);
|
||||
$this->size = $size;
|
||||
$this->color = $color;
|
||||
$this->width = $size;
|
||||
$this->height = $size;
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'projectile';
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
parent::update($dt);
|
||||
$this->time += $dt;
|
||||
|
||||
if (
|
||||
$this->pos->x < -$this->size
|
||||
|| $this->pos->x > $this->room->width + $this->size
|
||||
|| $this->pos->y < -$this->size
|
||||
|| $this->pos->y > $this->room->height + $this->size
|
||||
) {
|
||||
$this->container->remove($this);
|
||||
}
|
||||
}
|
||||
|
||||
public function toSnapshot(): array
|
||||
{
|
||||
return [
|
||||
'eid' => $this->eid,
|
||||
'd' => $this->kind(),
|
||||
'x' => round($this->pos->x, 1),
|
||||
'y' => round($this->pos->y, 1),
|
||||
'vx' => round($this->velocity->x, 1),
|
||||
'vy' => round($this->velocity->y, 1),
|
||||
's' => $this->size,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Entities;
|
||||
|
||||
use App\Game\EntityList;
|
||||
use App\Game\GameObject;
|
||||
use App\Game\Player;
|
||||
use App\Game\Room;
|
||||
use App\Game\Vector2;
|
||||
|
||||
class Zombie extends GameObject
|
||||
{
|
||||
public ?Player $target = null;
|
||||
|
||||
protected EntityList $container;
|
||||
|
||||
protected Room $room;
|
||||
|
||||
public float $speed;
|
||||
|
||||
public float $size;
|
||||
|
||||
/** @var array<float> */
|
||||
public array $baseColor;
|
||||
|
||||
public int $health;
|
||||
|
||||
public int $maxHealth;
|
||||
|
||||
public float $knockback;
|
||||
|
||||
public float $knockbackDamping;
|
||||
|
||||
public int $scoreValue;
|
||||
|
||||
/** @var null|callable(Zombie):void */
|
||||
public $onDeath;
|
||||
|
||||
protected Vector2 $hitKnockback;
|
||||
|
||||
protected float $flash = 0.0;
|
||||
|
||||
protected float $time = 0.0;
|
||||
|
||||
public float $broken;
|
||||
|
||||
protected float $emergeTotal;
|
||||
|
||||
protected float $emerge;
|
||||
|
||||
public function __construct(
|
||||
float $x,
|
||||
float $y,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
array $config = [],
|
||||
?callable $onDeath = null,
|
||||
) {
|
||||
parent::__construct($x, $y);
|
||||
$this->container = $container;
|
||||
$this->room = $room;
|
||||
$this->speed = $config['speed'] ?? 110.0;
|
||||
$this->size = $config['size'] ?? 24.0;
|
||||
$this->baseColor = $config['color'] ?? [0.15, 0.65, 0.2, 1.0];
|
||||
$this->health = $config['health'] ?? 1;
|
||||
$this->maxHealth = $this->health;
|
||||
$this->knockback = $config['knockback'] ?? 260.0;
|
||||
$this->knockbackDamping = $config['knockbackDamping'] ?? 10.0;
|
||||
$this->scoreValue = $config['scoreValue'] ?? 1;
|
||||
$emergeTime = $config['emergeTime'] ?? 0.0;
|
||||
$this->emergeTotal = $emergeTime;
|
||||
$this->emerge = $emergeTime;
|
||||
$this->onDeath = $onDeath;
|
||||
$this->broken = rand(0, 100000) / 100000.0 * M_PI * 2.0;
|
||||
$this->hitKnockback = Vector2::zero();
|
||||
$this->width = $this->size;
|
||||
$this->height = $this->size;
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'zombie';
|
||||
}
|
||||
|
||||
public function die(): void
|
||||
{
|
||||
if ($this->onDeath !== null) {
|
||||
($this->onDeath)($this);
|
||||
}
|
||||
$this->removeAllReferences();
|
||||
$this->container->remove($this);
|
||||
}
|
||||
|
||||
protected function removeAllReferences(): void {}
|
||||
|
||||
public function hit(Vector2 $impulse, int $damage = 1): bool
|
||||
{
|
||||
$this->health -= $damage;
|
||||
if ($this->health < 0) {
|
||||
$this->health = 0;
|
||||
}
|
||||
$this->hitKnockback = $impulse->mulS($this->knockback);
|
||||
$this->flash = 0.08;
|
||||
|
||||
return $this->health <= 0;
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
$this->time += $dt;
|
||||
if ($this->updateEmerge($dt)) {
|
||||
return;
|
||||
}
|
||||
if ($this->target === null) {
|
||||
return;
|
||||
}
|
||||
$delta = $this->target->pos->subV($this->pos);
|
||||
$distance = $delta->length();
|
||||
$chaseVelocity = Vector2::zero();
|
||||
if ($distance > 1.0) {
|
||||
$chaseVelocity = $delta->divS($distance)->mulS($this->speed);
|
||||
}
|
||||
|
||||
$this->velocity = $chaseVelocity->addV($this->hitKnockback);
|
||||
parent::update($dt);
|
||||
|
||||
$this->hitKnockback = $this->hitKnockback->mulS(max(0.0, 1 - $dt * $this->knockbackDamping));
|
||||
$this->flash = max(0.0, $this->flash - $dt);
|
||||
}
|
||||
|
||||
public function updateEmerge(float $dt): bool
|
||||
{
|
||||
if ($this->emerge <= 0.0) {
|
||||
return false;
|
||||
}
|
||||
$this->emerge -= $dt;
|
||||
if ($this->emerge <= 0.0) {
|
||||
$this->emerge = 0.0;
|
||||
$this->onEmergeComplete();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function onEmergeComplete(): void {}
|
||||
|
||||
protected function rand01(): float
|
||||
{
|
||||
return random_int(0, 1000000) / 1000000.0;
|
||||
}
|
||||
|
||||
public function toSnapshot(): array
|
||||
{
|
||||
$snapshot = [
|
||||
'eid' => $this->eid,
|
||||
'd' => $this->kind(),
|
||||
'x' => round($this->pos->x, 1),
|
||||
'y' => round($this->pos->y, 1),
|
||||
's' => $this->size,
|
||||
'hp' => $this->health,
|
||||
'mhp' => $this->maxHealth,
|
||||
'c' => array_slice($this->baseColor, 0, 3),
|
||||
];
|
||||
if ($this->emergeTotal > 0.0) {
|
||||
$snapshot['et'] = round($this->emergeTotal, 2);
|
||||
$snapshot['er'] = round($this->emerge, 2);
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game;
|
||||
|
||||
final class EntityList extends \ArrayObject
|
||||
{
|
||||
public function remove(GameObject $gameObject): void
|
||||
{
|
||||
foreach (array_keys($this->getArrayCopy()) as $key) {
|
||||
if ($this[$key] === $gameObject) {
|
||||
$this->offsetUnset($key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function findByKind(string $kind): array
|
||||
{
|
||||
$found = [];
|
||||
foreach ($this as $entity) {
|
||||
if ($entity->kind() === $kind) {
|
||||
$found[] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/** Snapshot copy so entities added mid-iteration are not touched. */
|
||||
public function copy(): array
|
||||
{
|
||||
$copy = [];
|
||||
foreach ($this as $entity) {
|
||||
$copy[] = $entity;
|
||||
}
|
||||
|
||||
return $copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game;
|
||||
|
||||
abstract class GameObject
|
||||
{
|
||||
private static int $nextId = 0;
|
||||
|
||||
public readonly int $eid;
|
||||
|
||||
public Vector2 $pos;
|
||||
|
||||
public Vector2 $velocity;
|
||||
|
||||
public float $width;
|
||||
|
||||
public float $height;
|
||||
|
||||
public function __construct(float $x = 0.0, float $y = 0.0)
|
||||
{
|
||||
$this->eid = ++self::$nextId;
|
||||
$this->pos = new Vector2($x, $y);
|
||||
$this->velocity = Vector2::zero();
|
||||
$this->width = 0.0;
|
||||
$this->height = 0.0;
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
$this->move($dt);
|
||||
}
|
||||
|
||||
/** Integrate position from velocity; used directly by subclasses that override update(). */
|
||||
public function move(float $dt): void
|
||||
{
|
||||
$this->pos = new Vector2(
|
||||
$this->pos->x + $this->velocity->x * $dt,
|
||||
$this->pos->y + $this->velocity->y * $dt,
|
||||
);
|
||||
}
|
||||
|
||||
public function intersects(GameObject $other): bool
|
||||
{
|
||||
return abs($this->pos->x - $other->pos->x) * 2.0 < $this->width + $other->width
|
||||
&& abs($this->pos->y - $other->pos->y) * 2.0 < $this->height + $other->height;
|
||||
}
|
||||
|
||||
public function removeFrom(EntityList $container): void
|
||||
{
|
||||
$container->remove($this);
|
||||
}
|
||||
|
||||
abstract public function kind(): string;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game;
|
||||
|
||||
use App\Events\GameRoomError;
|
||||
use App\Events\GameSnapshot;
|
||||
|
||||
/**
|
||||
* Owns the in-memory world (rooms, players, inputs) and advances it forward.
|
||||
*
|
||||
* Queue draining (Redis I/O) is the concern of the caller; this class only
|
||||
* applies already-decoded control/input frames and steps the sim.
|
||||
*/
|
||||
final class GameSimulator
|
||||
{
|
||||
public const INPUT_QUEUE = 'game:in:%s';
|
||||
|
||||
public const CONTROL_QUEUE = 'game:ctrl:%s';
|
||||
|
||||
private const SNAPSHOT_EVERY = 2;
|
||||
|
||||
/** @var array<string, array{room: Room, engine: GameEngine, spawner: Spawner, inputs: array<string, InputState>}> */
|
||||
private array $world = [];
|
||||
|
||||
private int $frame = 0;
|
||||
|
||||
/** @var callable(Room, array<int, mixed>, array<int, mixed>, array<int, mixed>): void */
|
||||
private $broadcaster;
|
||||
|
||||
/** @var callable(string, string): void */
|
||||
private $errorReporter;
|
||||
|
||||
public function __construct(?callable $broadcaster = null, ?callable $errorReporter = null)
|
||||
{
|
||||
$this->broadcaster = $broadcaster ?? function (Room $room, array $players, array $entities, array $events): void {
|
||||
GameSnapshot::dispatch($room, $players, $entities, $events);
|
||||
};
|
||||
$this->errorReporter = $errorReporter ?? static function (string $roomId, string $code): void {
|
||||
GameRoomError::dispatch($roomId, $code);
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<string, string> room id to Room id (for introspection/tests) */
|
||||
public function roomIds(): array
|
||||
{
|
||||
return array_keys($this->world);
|
||||
}
|
||||
|
||||
public function frame(): int
|
||||
{
|
||||
return $this->frame;
|
||||
}
|
||||
|
||||
public function room(string $id): ?Room
|
||||
{
|
||||
return $this->world[$id]['room'] ?? null;
|
||||
}
|
||||
|
||||
public function applyControl(string $roomId, array $frame): void
|
||||
{
|
||||
$type = $frame['type'] ?? null;
|
||||
|
||||
if ($type === 'create') {
|
||||
$this->create($roomId, (string) ($frame['id'] ?? ''), (string) ($frame['name'] ?? 'Player'));
|
||||
} elseif ($type === 'join') {
|
||||
$this->join($roomId, (string) ($frame['id'] ?? ''), (string) ($frame['name'] ?? 'Player'));
|
||||
} elseif ($type === 'leave') {
|
||||
$this->leave($roomId, (string) ($frame['id'] ?? ''));
|
||||
} elseif ($type === 'start') {
|
||||
$entry = $this->world[$roomId] ?? null;
|
||||
if ($entry !== null) {
|
||||
$room = $entry['room'];
|
||||
if (
|
||||
$room->state === Room::STATE_LOBBY
|
||||
&& $room->players !== []
|
||||
&& (string) ($frame['id'] ?? '') === $room->hostId
|
||||
) {
|
||||
$room->state = Room::STATE_PLAYING;
|
||||
$room->pickupTimer = 8.0;
|
||||
$room->spawnTimer = 0.0;
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'reset') {
|
||||
$this->reset($roomId, (string) ($frame['id'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-only: create a brand-new room with this client as host. */
|
||||
private function create(string $roomId, string $playerId, string $name): void
|
||||
{
|
||||
if ($playerId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! isset($this->world[$roomId])) {
|
||||
$this->createRoom($roomId, $playerId, $name);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$room = $this->world[$roomId]['room'];
|
||||
if ($room->hostId !== $playerId) {
|
||||
$this->reject($roomId, 'room_exists');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addPlayer($roomId, $playerId, $name);
|
||||
}
|
||||
|
||||
/** Guests may only join rooms that already exist and are open. */
|
||||
private function join(string $roomId, string $playerId, string $name): void
|
||||
{
|
||||
if ($playerId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! isset($this->world[$roomId])) {
|
||||
$this->reject($roomId, 'room_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$room = $this->world[$roomId]['room'];
|
||||
if ($room->state !== Room::STATE_LOBBY) {
|
||||
$this->broadcastRoom($room);
|
||||
$this->reject($roomId, 'room_not_open');
|
||||
|
||||
return;
|
||||
}
|
||||
if (isset($room->players[$playerId])) {
|
||||
return;
|
||||
}
|
||||
if (count($room->players) >= $room->maxPlayers) {
|
||||
$this->broadcastRoom($room);
|
||||
$this->reject($roomId, 'room_full');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addPlayer($roomId, $playerId, $name);
|
||||
}
|
||||
|
||||
private function createRoom(string $roomId, string $playerId, string $name): void
|
||||
{
|
||||
$room = new Room($roomId);
|
||||
$room->state = Room::STATE_LOBBY;
|
||||
$room->hostId = $playerId;
|
||||
$spawner = new Spawner;
|
||||
$this->world[$roomId] = [
|
||||
'room' => $room,
|
||||
'engine' => new GameEngine($room, $spawner),
|
||||
'spawner' => $spawner,
|
||||
'inputs' => [],
|
||||
];
|
||||
|
||||
$this->addPlayer($roomId, $playerId, $name);
|
||||
}
|
||||
|
||||
private function addPlayer(string $roomId, string $playerId, string $name): void
|
||||
{
|
||||
$entry = $this->world[$roomId];
|
||||
$room = $entry['room'];
|
||||
|
||||
if (isset($room->players[$playerId])) {
|
||||
return;
|
||||
}
|
||||
if (count($room->players) >= $room->maxPlayers || $room->state !== Room::STATE_LOBBY) {
|
||||
$this->broadcastRoom($room);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$input = new InputState;
|
||||
$player = new Player($playerId, $name, $input, $room->entities, $room);
|
||||
$room->addPlayer($player);
|
||||
$entry['inputs'][$playerId] = $input;
|
||||
$this->world[$roomId] = $entry;
|
||||
|
||||
$this->broadcastRoom($room);
|
||||
}
|
||||
|
||||
private function reject(string $roomId, string $code): void
|
||||
{
|
||||
($this->errorReporter)($roomId, $code);
|
||||
}
|
||||
|
||||
private function leave(string $roomId, string $playerId): void
|
||||
{
|
||||
if (! isset($this->world[$roomId])) {
|
||||
return;
|
||||
}
|
||||
$entry = $this->world[$roomId];
|
||||
$room = $entry['room'];
|
||||
$player = $room->players[$playerId] ?? null;
|
||||
if ($player !== null) {
|
||||
$room->removePlayer($player);
|
||||
if ($room->hostId === $playerId && $room->players !== []) {
|
||||
$room->hostId = (string) array_key_first($room->players);
|
||||
}
|
||||
}
|
||||
unset($entry['inputs'][$playerId]);
|
||||
$this->world[$roomId] = $entry;
|
||||
|
||||
$this->broadcastRoom($room);
|
||||
|
||||
if ($room->players === []) {
|
||||
unset($this->world[$roomId]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-only: send a finished room back to the lobby for another round. */
|
||||
private function reset(string $roomId, string $playerId): void
|
||||
{
|
||||
$room = $this->world[$roomId]['room'] ?? null;
|
||||
if ($room === null || $playerId === '' || $room->hostId !== $playerId) {
|
||||
return;
|
||||
}
|
||||
if ($room->state !== Room::STATE_OVER) {
|
||||
return;
|
||||
}
|
||||
|
||||
$room->reset();
|
||||
$this->broadcastRoom($room);
|
||||
}
|
||||
|
||||
public function applyInput(string $roomId, array $frame): void
|
||||
{
|
||||
$entry = $this->world[$roomId] ?? null;
|
||||
if ($entry === null) {
|
||||
return;
|
||||
}
|
||||
$input = $entry['inputs'][(string) ($frame['id'] ?? '')] ?? null;
|
||||
if ($input === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keys = is_array($frame['keys'] ?? null) ? $frame['keys'] : [];
|
||||
$input->keys = array_values(array_filter($keys, 'is_string'));
|
||||
$input->fire = (bool) ($frame['fire'] ?? false);
|
||||
$input->autoAim = (bool) ($frame['autoAim'] ?? false);
|
||||
|
||||
$aim = $frame['aim'] ?? null;
|
||||
if (is_array($aim)) {
|
||||
$input->aim = new Vector2(
|
||||
(float) ($aim['x'] ?? 0.0),
|
||||
(float) ($aim['y'] ?? 0.0),
|
||||
);
|
||||
} else {
|
||||
$input->aim = new Vector2(1.0, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
public function tick(float $dt): void
|
||||
{
|
||||
$this->frame++;
|
||||
foreach (array_keys($this->world) as $roomId) {
|
||||
$entry = $this->world[$roomId];
|
||||
$room = $entry['room'];
|
||||
if ($room->state !== Room::STATE_PLAYING) {
|
||||
continue;
|
||||
}
|
||||
$entry['engine']->tick($dt);
|
||||
|
||||
if ($room->state === Room::STATE_OVER) {
|
||||
$room->seq++;
|
||||
$this->broadcastRoom($room);
|
||||
} elseif ($this->frame % self::SNAPSHOT_EVERY === 0) {
|
||||
$room->seq++;
|
||||
$this->broadcastRoom($room);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function broadcastRoom(Room $room): void
|
||||
{
|
||||
if ($room->players === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$players = [];
|
||||
foreach ($room->players as $player) {
|
||||
$players[] = $player->toSnapshot();
|
||||
}
|
||||
|
||||
$entities = [];
|
||||
foreach ($room->entities as $entity) {
|
||||
if ($entity instanceof Player) {
|
||||
continue;
|
||||
}
|
||||
if ($entity instanceof GameObject) {
|
||||
$entities[] = $entity->toSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
($this->broadcaster)($room, $players, $entities, $room->flushEvents());
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function decode(string $payload): ?array
|
||||
{
|
||||
$frame = json_decode($payload, true);
|
||||
|
||||
return is_array($frame) ? $frame : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game;
|
||||
|
||||
final class InputState
|
||||
{
|
||||
/** @var array<string> Currently-held keys (lowercase: w/a/s/d/space/shift). */
|
||||
public array $keys;
|
||||
|
||||
/** Normalized aim direction in world space, sent by the client. */
|
||||
public Vector2 $aim;
|
||||
|
||||
public bool $fire;
|
||||
|
||||
public bool $autoAim;
|
||||
|
||||
public function __construct(
|
||||
array $keys = [],
|
||||
?Vector2 $aim = null,
|
||||
bool $fire = false,
|
||||
bool $autoAim = false,
|
||||
) {
|
||||
$this->keys = $keys;
|
||||
$this->aim = $aim ?? new Vector2(1.0, 0.0);
|
||||
$this->fire = $fire;
|
||||
$this->autoAim = $autoAim;
|
||||
}
|
||||
|
||||
public function reset(?Vector2 $aim = null): void
|
||||
{
|
||||
$this->keys = [];
|
||||
$this->aim = $aim ?? new Vector2(1.0, 0.0);
|
||||
$this->fire = false;
|
||||
$this->autoAim = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game;
|
||||
|
||||
use App\Game\Entities\Bullet;
|
||||
use App\Game\Entities\Zombie;
|
||||
|
||||
final class Player extends GameObject
|
||||
{
|
||||
public InputState $input;
|
||||
|
||||
private EntityList $container;
|
||||
|
||||
private Room $room;
|
||||
|
||||
public float $moveSpeed;
|
||||
|
||||
public float $fireRate;
|
||||
|
||||
public float $recoilSpeed;
|
||||
|
||||
public float $recoilDamping;
|
||||
|
||||
public float $bulletSpeed;
|
||||
|
||||
public float $bulletSize;
|
||||
|
||||
public int $bulletCount;
|
||||
|
||||
public float $bulletSpread;
|
||||
|
||||
public int $damage;
|
||||
|
||||
private float $baseFireRate;
|
||||
|
||||
private int $baseDamage;
|
||||
|
||||
private int $baseBulletCount;
|
||||
|
||||
private float $baseBulletSpeed;
|
||||
|
||||
public int $maxUpgradeLevel;
|
||||
|
||||
/** @var array<string, int> */
|
||||
public array $upgrades = ['rapid' => 0, 'damage' => 0, 'multishot' => 0, 'speed' => 0];
|
||||
|
||||
public float $size;
|
||||
|
||||
public string $name;
|
||||
|
||||
public string $id;
|
||||
|
||||
public int $health;
|
||||
|
||||
public int $maxHealth;
|
||||
|
||||
public int $score = 0;
|
||||
|
||||
public bool $dead = false;
|
||||
|
||||
public bool $autoAim = false;
|
||||
|
||||
private float $cooldown = 0.0;
|
||||
|
||||
private Vector2 $aimDirection;
|
||||
|
||||
private Vector2 $recoil;
|
||||
|
||||
private float $damageCooldown = 0.0;
|
||||
|
||||
private float $time = 0.0;
|
||||
|
||||
public function __construct(
|
||||
string $playerId,
|
||||
string $name,
|
||||
InputState $input,
|
||||
EntityList $container,
|
||||
Room $room,
|
||||
array $config = [],
|
||||
) {
|
||||
parent::__construct(
|
||||
$config['x'] ?? $room->width / 2.0,
|
||||
$config['y'] ?? $room->height / 2.0,
|
||||
);
|
||||
$this->input = $input;
|
||||
$this->container = $container;
|
||||
$this->room = $room;
|
||||
$this->id = $playerId;
|
||||
$this->name = $name;
|
||||
$this->moveSpeed = $config['moveSpeed'] ?? 420.0;
|
||||
$this->fireRate = $config['fireRate'] ?? 0.2;
|
||||
$this->recoilSpeed = $config['recoilSpeed'] ?? 100.0;
|
||||
$this->recoilDamping = $config['recoilDamping'] ?? 15.0;
|
||||
$this->bulletSpeed = $config['bulletSpeed'] ?? 800.0;
|
||||
$this->bulletSize = $config['bulletSize'] ?? 8.0;
|
||||
$this->bulletCount = $config['bulletCount'] ?? 1;
|
||||
$this->bulletSpread = $config['bulletSpread'] ?? 0.09;
|
||||
$this->damage = $config['damage'] ?? 1;
|
||||
$this->baseFireRate = $this->fireRate;
|
||||
$this->baseDamage = $this->damage;
|
||||
$this->baseBulletCount = $this->bulletCount;
|
||||
$this->baseBulletSpeed = $this->bulletSpeed;
|
||||
$this->maxUpgradeLevel = $config['maxUpgradeLevel'] ?? 3;
|
||||
$this->health = $config['health'] ?? 3;
|
||||
$this->maxHealth = $this->health;
|
||||
$this->size = $config['size'] ?? 24.0;
|
||||
$this->width = $this->size;
|
||||
$this->height = $this->size;
|
||||
$this->aimDirection = new Vector2(1.0, 0.0);
|
||||
$this->recoil = Vector2::zero();
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return 'player';
|
||||
}
|
||||
|
||||
public function update(float $dt): void
|
||||
{
|
||||
$this->time += $dt;
|
||||
|
||||
$movement = new Vector2(0.0, 0.0);
|
||||
$keys = $this->input->keys;
|
||||
if (in_array('w', $keys, true) || in_array('arrowup', $keys, true)) {
|
||||
$movement = new Vector2($movement->x, $movement->y - 1.0);
|
||||
}
|
||||
if (in_array('s', $keys, true) || in_array('arrowdown', $keys, true)) {
|
||||
$movement = new Vector2($movement->x, $movement->y + 1.0);
|
||||
}
|
||||
if (in_array('a', $keys, true) || in_array('arrowleft', $keys, true)) {
|
||||
$movement = new Vector2($movement->x - 1.0, $movement->y);
|
||||
}
|
||||
if (in_array('d', $keys, true) || in_array('arrowright', $keys, true)) {
|
||||
$movement = new Vector2($movement->x + 1.0, $movement->y);
|
||||
}
|
||||
|
||||
$moveVelocity = Vector2::zero();
|
||||
if ($movement->lengthSquared() > 0.0) {
|
||||
$moveVelocity = $movement->normalize()->mulS($this->moveSpeed);
|
||||
}
|
||||
|
||||
$this->velocity = $this->recoil->lengthSquared() > 1.0
|
||||
? $moveVelocity->addV($this->recoil)
|
||||
: $moveVelocity;
|
||||
parent::update($dt);
|
||||
|
||||
$maxX = max($this->width / 2.0, 0.0);
|
||||
$maxY = max($this->height / 2.0, 0.0);
|
||||
$this->pos = new Vector2(
|
||||
min(max($this->pos->x, $maxX), $this->room->width - $maxX),
|
||||
min(max($this->pos->y, $maxY), $this->room->height - $maxY),
|
||||
);
|
||||
|
||||
$aim = $this->input->aim;
|
||||
$autoAimActive = $this->input->autoAim || $this->autoAim;
|
||||
$hasTarget = false;
|
||||
if ($autoAimActive) {
|
||||
$nearest = $this->nearestZombie();
|
||||
if ($nearest !== null) {
|
||||
$hasTarget = true;
|
||||
$toEnemy = $nearest->pos->subV($this->pos);
|
||||
if ($toEnemy->lengthSquared() > 1.0) {
|
||||
$aim = $toEnemy;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($aim->lengthSquared() > 1.0) {
|
||||
$this->aimDirection = $aim->divS($aim->length());
|
||||
} elseif ($aim->lengthSquared() > 0.01 && $this->input->aim !== null && ! $autoAimActive) {
|
||||
// Raw normalized direction from the client when not auto-aiming.
|
||||
$this->aimDirection = $aim->normalize();
|
||||
}
|
||||
|
||||
$this->recoil = $this->recoil->mulS(max(0.0, 1 - $dt * $this->recoilDamping));
|
||||
$this->damageCooldown = max(0.0, $this->damageCooldown - $dt);
|
||||
|
||||
$this->cooldown -= $dt;
|
||||
if ($this->cooldown <= 0.0 && ($this->input->fire || ($autoAimActive && $hasTarget))) {
|
||||
$this->cooldown = $this->fireRate;
|
||||
$this->shoot();
|
||||
}
|
||||
}
|
||||
|
||||
public function nearestZombie(): ?Zombie
|
||||
{
|
||||
$best = null;
|
||||
$bestDistSq = INF;
|
||||
foreach ($this->container as $obj) {
|
||||
if (! $obj instanceof Zombie) {
|
||||
continue;
|
||||
}
|
||||
$delta = $obj->pos->subV($this->pos);
|
||||
$distSq = $delta->lengthSquared();
|
||||
if ($distSq < $bestDistSq) {
|
||||
$bestDistSq = $distSq;
|
||||
$best = $obj;
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
public function shoot(): void
|
||||
{
|
||||
$aimAngle = atan2($this->aimDirection->y, $this->aimDirection->x);
|
||||
$this->recoil = $this->aimDirection->mulS(-$this->recoilSpeed);
|
||||
|
||||
$offset = (($this->bulletCount - 1) / 2.0) * $this->bulletSpread;
|
||||
for ($i = 0; $i < $this->bulletCount; $i++) {
|
||||
$angle = $aimAngle - $offset + $i * $this->bulletSpread;
|
||||
$direction = new Vector2(cos($angle), sin($angle));
|
||||
$bullet = new Bullet(
|
||||
$this->pos->x,
|
||||
$this->pos->y,
|
||||
$direction,
|
||||
$this->container,
|
||||
$this->room,
|
||||
$this,
|
||||
$this->bulletSpeed,
|
||||
$this->bulletSize,
|
||||
$this->damage,
|
||||
);
|
||||
$this->container->append($bullet);
|
||||
}
|
||||
}
|
||||
|
||||
public function applyUpgrade(string $type): bool
|
||||
{
|
||||
if (! array_key_exists($type, $this->upgrades)) {
|
||||
return false;
|
||||
}
|
||||
if ($this->upgrades[$type] >= $this->maxUpgradeLevel) {
|
||||
return false;
|
||||
}
|
||||
$this->upgrades[$type] += 1;
|
||||
switch ($type) {
|
||||
case 'rapid':
|
||||
$this->fireRate = max(0.06, $this->fireRate * 0.8);
|
||||
break;
|
||||
case 'damage':
|
||||
$this->damage += 1;
|
||||
break;
|
||||
case 'multishot':
|
||||
$this->bulletCount = min(7, $this->bulletCount + 1);
|
||||
break;
|
||||
case 'speed':
|
||||
$this->bulletSpeed = min(1800.0, $this->bulletSpeed * 1.15);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function toSnapshot(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'x' => round($this->pos->x, 1),
|
||||
'y' => round($this->pos->y, 1),
|
||||
'aimX' => round($this->aimDirection->x, 3),
|
||||
'aimY' => round($this->aimDirection->y, 3),
|
||||
'hp' => $this->health,
|
||||
'mhp' => $this->maxHealth,
|
||||
'score' => $this->score,
|
||||
'dead' => $this->dead,
|
||||
'host' => $this->room->hostId === $this->id,
|
||||
'up' => $this->upgrades,
|
||||
];
|
||||
}
|
||||
|
||||
/** Reset per-round state back to lobby defaults (keeps id/name/host). */
|
||||
public function resetForRound(): void
|
||||
{
|
||||
$this->pos = new Vector2($this->room->width / 2.0, $this->room->height / 2.0);
|
||||
$this->velocity = Vector2::zero();
|
||||
$this->recoil = Vector2::zero();
|
||||
$this->aimDirection = new Vector2(1.0, 0.0);
|
||||
$this->health = $this->maxHealth;
|
||||
$this->dead = false;
|
||||
$this->score = 0;
|
||||
$this->upgrades = ['rapid' => 0, 'damage' => 0, 'multishot' => 0, 'speed' => 0];
|
||||
$this->fireRate = $this->baseFireRate;
|
||||
$this->bulletSpeed = $this->baseBulletSpeed;
|
||||
$this->bulletCount = $this->baseBulletCount;
|
||||
$this->damage = $this->baseDamage;
|
||||
$this->cooldown = 0.0;
|
||||
$this->damageCooldown = 0.0;
|
||||
$this->time = 0.0;
|
||||
}
|
||||
|
||||
public function takeDamage(): void
|
||||
{
|
||||
if ($this->damageCooldown > 0.0) {
|
||||
return;
|
||||
}
|
||||
$this->damageCooldown = 0.5;
|
||||
$this->health -= 1;
|
||||
if ($this->health <= 0 && ! $this->dead) {
|
||||
$this->dead = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function addScore(int $points): void
|
||||
{
|
||||
$this->score += $points;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game;
|
||||
|
||||
final class Room
|
||||
{
|
||||
public const STATE_LOBBY = 'lobby';
|
||||
|
||||
public const STATE_PLAYING = 'playing';
|
||||
|
||||
public const STATE_OVER = 'over';
|
||||
|
||||
public float $width = 1920.0;
|
||||
|
||||
public float $height = 1080.0;
|
||||
|
||||
public EntityList $entities;
|
||||
|
||||
/** @var array<string, Player> playerId => Player */
|
||||
public array $players = [];
|
||||
|
||||
public string $state = self::STATE_LOBBY;
|
||||
|
||||
/** Player id of the room's host (the first player to join). */
|
||||
public string $hostId = '';
|
||||
|
||||
public float $elapsed = 0.0;
|
||||
|
||||
public float $nextBossScore = 50.0;
|
||||
|
||||
public int $bossCount = 0;
|
||||
|
||||
public float $bossCooldown = 0.0;
|
||||
|
||||
public float $spawnTimer = 0.0;
|
||||
|
||||
public float $pickupTimer = 8.0;
|
||||
|
||||
/** @var array<int, array<string, mixed>> Queue of one-shot event descriptors for clients. */
|
||||
public array $events = [];
|
||||
|
||||
public int $seq = 0;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly int $maxPlayers = 8,
|
||||
) {
|
||||
$this->entities = new EntityList;
|
||||
}
|
||||
|
||||
public function addPlayer(Player $player): void
|
||||
{
|
||||
$this->players[$player->id] = $player;
|
||||
$this->entities->append($player);
|
||||
}
|
||||
|
||||
public function removePlayer(Player $player): void
|
||||
{
|
||||
unset($this->players[$player->id]);
|
||||
$this->entities->remove($player);
|
||||
}
|
||||
|
||||
/** Reset round state so the room can be played again (host-initiated). */
|
||||
public function reset(): void
|
||||
{
|
||||
$this->state = self::STATE_LOBBY;
|
||||
$this->elapsed = 0.0;
|
||||
$this->nextBossScore = 50.0;
|
||||
$this->bossCount = 0;
|
||||
$this->bossCooldown = 0.0;
|
||||
$this->spawnTimer = 0.0;
|
||||
$this->pickupTimer = 8.0;
|
||||
$this->events = [];
|
||||
$this->seq++;
|
||||
|
||||
foreach ($this->entities->copy() as $entity) {
|
||||
if ($entity instanceof Player) {
|
||||
continue;
|
||||
}
|
||||
$this->entities->remove($entity);
|
||||
}
|
||||
|
||||
foreach ($this->players as $player) {
|
||||
$player->resetForRound();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int, Player> */
|
||||
public function alivePlayers(): array
|
||||
{
|
||||
$alive = [];
|
||||
foreach ($this->players as $player) {
|
||||
if (! $player->dead) {
|
||||
$alive[] = $player;
|
||||
}
|
||||
}
|
||||
|
||||
return $alive;
|
||||
}
|
||||
|
||||
public function highestScore(): int
|
||||
{
|
||||
$best = 0;
|
||||
foreach ($this->players as $player) {
|
||||
$best = max($best, $player->score);
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
public function hasBoss(): bool
|
||||
{
|
||||
foreach ($this->entities as $entity) {
|
||||
if ($entity instanceof Entities\Boss) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function pushEvent(string $kind, array $data = []): void
|
||||
{
|
||||
$this->events[] = ['k' => $kind, 'x' => $data['x'] ?? 0.0, 'y' => $data['y'] ?? 0.0, 'd' => $data];
|
||||
}
|
||||
|
||||
public function flushEvents(): array
|
||||
{
|
||||
$events = $this->events;
|
||||
$this->events = [];
|
||||
|
||||
return $events;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game;
|
||||
|
||||
use App\Game\Entities\Boss;
|
||||
use App\Game\Entities\Leaper;
|
||||
use App\Game\Entities\MegaTank;
|
||||
use App\Game\Entities\MegaWitch;
|
||||
use App\Game\Entities\Pickup;
|
||||
use App\Game\Entities\Witch;
|
||||
use App\Game\Entities\Zombie;
|
||||
|
||||
final class Spawner
|
||||
{
|
||||
public function spawnInterval(float $elapsed): float
|
||||
{
|
||||
return max(0.35, 1.2 - $elapsed * 0.008);
|
||||
}
|
||||
|
||||
public function speedMultiplier(float $elapsed): float
|
||||
{
|
||||
return min(2.2, 1.0 + $elapsed * 0.004);
|
||||
}
|
||||
|
||||
private function tankChance(float $elapsed): float
|
||||
{
|
||||
return min(0.5, 0.15 + $elapsed * 0.002);
|
||||
}
|
||||
|
||||
private function megaTankChance(float $elapsed): float
|
||||
{
|
||||
return min(0.22, $elapsed * 0.0015);
|
||||
}
|
||||
|
||||
private function runnerChance(float $elapsed): float
|
||||
{
|
||||
return min(0.4, 0.2 + $elapsed * 0.001);
|
||||
}
|
||||
|
||||
private function leaperChance(float $elapsed): float
|
||||
{
|
||||
return min(0.12, 0.04 + $elapsed * 0.06);
|
||||
}
|
||||
|
||||
private function witchChance(float $elapsed): float
|
||||
{
|
||||
return min(0.3, 0.1 + $elapsed * 0.002);
|
||||
}
|
||||
|
||||
private function megaWitchChance(float $elapsed): float
|
||||
{
|
||||
return $elapsed < 45.0 ? 0.0 : min(0.12, ($elapsed - 45.0) * 0.0008);
|
||||
}
|
||||
|
||||
public function spawnZombie(Room $room): void
|
||||
{
|
||||
$elapsed = $room->elapsed;
|
||||
$width = $room->width;
|
||||
$height = $room->height;
|
||||
$speedMultiplier = $this->speedMultiplier($elapsed);
|
||||
|
||||
$isMegaTank = $this->chance($this->megaTankChance($elapsed));
|
||||
$isMegaWitch = ! $isMegaTank && $this->chance($this->megaWitchChance($elapsed));
|
||||
$isWitch = ! $isMegaTank && ! $isMegaWitch && $this->chance($this->witchChance($elapsed));
|
||||
$isRunner = ! $isMegaTank && ! $isMegaWitch && ! $isWitch && $this->chance($this->runnerChance($elapsed));
|
||||
$isLeaper = ! $isMegaTank && ! $isMegaWitch && ! $isWitch && ! $isRunner && $this->chance($this->leaperChance($elapsed));
|
||||
$isTank = ! $isMegaTank && ! $isMegaWitch && ! $isWitch && ! $isRunner && ! $isLeaper && $this->chance($this->tankChance($elapsed));
|
||||
|
||||
$size = $isMegaWitch ? 52.0 : ($isWitch ? 30.0 : ($isRunner ? 14.0 : ($isLeaper ? 28.0 : ($isTank ? 36.0 : 24.0))));
|
||||
|
||||
[$x, $y] = $this->edgePosition($room, $size);
|
||||
|
||||
if ($isMegaTank) {
|
||||
$room->entities->append(new MegaTank($x, $y, $room->entities, $room, $this->initialTarget($room), [
|
||||
'speed' => 28.0 + $this->rand() * 12.0,
|
||||
'size' => 64.0,
|
||||
'health' => 18,
|
||||
'knockback' => 90.0,
|
||||
'scoreValue' => 15,
|
||||
'spawnCount' => 3,
|
||||
'color' => [0.55, 0.28, 0.12, 1.0],
|
||||
'spawnOnDeath' => function (float $sx, float $sy, EntityList $container, Room $sroom, ?Player $starget): void {
|
||||
$container->append(new Zombie($sx, $sy, $container, $sroom, [
|
||||
'speed' => (70.0 + $this->rand() * 60.0) * $this->speedMultiplier($sroom->elapsed),
|
||||
'size' => 24.0,
|
||||
'health' => 1,
|
||||
'knockback' => 260.0,
|
||||
'scoreValue' => 1,
|
||||
'color' => [0.15, 0.65, 0.2, 1.0],
|
||||
'emergeTime' => 0.8,
|
||||
], fn (Zombie $z) => $z->target = $starget));
|
||||
$sroom->pushEvent('portal', ['x' => $sx, 'y' => $sy, 'color' => [0.15, 0.65, 0.2, 1.0], 'scale' => 0.8]);
|
||||
},
|
||||
], null));
|
||||
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.55, 0.28, 0.12, 1.0], 'scale' => 2.2]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isMegaWitch) {
|
||||
$megaPower = min(1.0, max(0.0, ($elapsed - 45.0) / 90.0));
|
||||
$this->spawnMegaWitch($room, $x, $y, $speedMultiplier, $megaPower);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isWitch) {
|
||||
$this->spawnWitch($room, $x, $y, $speedMultiplier);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isRunner) {
|
||||
$room->entities->append(new Zombie($x, $y, $room->entities, $room, [
|
||||
'speed' => (180.0 + $this->rand() * 60.0) * $speedMultiplier,
|
||||
'size' => $size,
|
||||
'health' => 1,
|
||||
'knockback' => 380.0,
|
||||
'scoreValue' => 2,
|
||||
'color' => [0.85, 0.1, 0.2, 1.0],
|
||||
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
|
||||
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.85, 0.1, 0.2, 1.0], 'scale' => 0.9]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isLeaper) {
|
||||
$this->spawnLeaper($room, $x, $y, $speedMultiplier);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$zombieColor = $isTank ? [0.3, 0.35, 0.15, 1.0] : [0.15, 0.65, 0.2, 1.0];
|
||||
$room->entities->append(new Zombie($x, $y, $room->entities, $room, [
|
||||
'speed' => ($isTank ? 45.0 : 80.0 + $this->rand() * 60.0) * $speedMultiplier,
|
||||
'size' => $size,
|
||||
'health' => $isTank ? 4 : 1,
|
||||
'knockback' => $isTank ? 160.0 : 260.0,
|
||||
'scoreValue' => $isTank ? 3 : 1,
|
||||
'color' => $zombieColor,
|
||||
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
|
||||
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => $zombieColor, 'scale' => $isTank ? 1.5 : 1.0]);
|
||||
}
|
||||
|
||||
public function spawnWitch(Room $room, float $x, float $y, float $speedMultiplier = 1.0, int $emergeTime = 0): void
|
||||
{
|
||||
$room->entities->append(new Witch($x, $y, $room->entities, $room, $this->initialTarget($room), [
|
||||
'speed' => (70.0 + $this->rand() * 40.0) * $speedMultiplier,
|
||||
'size' => 30.0,
|
||||
'health' => 5,
|
||||
'knockback' => 180.0,
|
||||
'scoreValue' => 4,
|
||||
'color' => [0.55, 0.2, 0.8, 1.0],
|
||||
'preferredDistance' => 220.0,
|
||||
'fireInterval' => 2.2,
|
||||
'projectileSpeed' => 220.0,
|
||||
'emergeTime' => $emergeTime,
|
||||
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
|
||||
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.55, 0.2, 0.8, 1.0], 'scale' => 1.2]);
|
||||
}
|
||||
|
||||
public function spawnMegaWitch(Room $room, float $x, float $y, float $speedMultiplier = 1.0, float $power = 1.0): void
|
||||
{
|
||||
$room->entities->append(new MegaWitch($x, $y, $room->entities, $room, $this->initialTarget($room), [
|
||||
'speed' => 55.0 * $speedMultiplier,
|
||||
'size' => 52.0,
|
||||
'health' => (int) round(8.0 + 14.0 * $power),
|
||||
'knockback' => 130.0,
|
||||
'scoreValue' => 12,
|
||||
'color' => [0.8, 0.15, 0.7, 1.0],
|
||||
'preferredDistance' => 240.0,
|
||||
'fireInterval' => 2.0,
|
||||
'projectileSpeed' => 240.0,
|
||||
'projectileCount' => 3,
|
||||
'projectileSpread' => 0.35,
|
||||
'projectileColor' => [1.0, 0.3, 0.3, 1.0],
|
||||
'novaInterval' => 6.0 - $power,
|
||||
'novaCount' => (int) round(10.0 + 6.0 * $power),
|
||||
'novaProjectileSpeed' => 210.0 + 40.0 * $power,
|
||||
'spawnCount' => 3,
|
||||
'spawnOnDeath' => function (float $sx, float $sy, EntityList $container, Room $sroom, ?Player $starget): void {
|
||||
$container->append(new Witch($sx, $sy, $container, $sroom, $starget, [
|
||||
'speed' => 70.0 + $this->rand() * 40.0,
|
||||
'size' => 30.0,
|
||||
'health' => 5,
|
||||
'knockback' => 180.0,
|
||||
'scoreValue' => 4,
|
||||
'color' => [0.55, 0.2, 0.8, 1.0],
|
||||
'preferredDistance' => 220.0,
|
||||
'fireInterval' => 2.2,
|
||||
'projectileSpeed' => 220.0,
|
||||
'emergeTime' => 1.1,
|
||||
], fn (Zombie $z) => $z->target = $starget));
|
||||
$sroom->pushEvent('portal', ['x' => $sx, 'y' => $sy, 'color' => [0.55, 0.2, 0.8, 1.0], 'scale' => 1.2]);
|
||||
},
|
||||
], null));
|
||||
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.8, 0.15, 0.7, 1.0], 'scale' => 2.2]);
|
||||
}
|
||||
|
||||
public function spawnLeaper(Room $room, float $x, float $y, float $speedMultiplier = 1.0, int $emergeTime = 0): void
|
||||
{
|
||||
$room->entities->append(new Leaper($x, $y, $room->entities, $room, $this->initialTarget($room), [
|
||||
'speed' => (80.0 + $this->rand() * 40.0) * $speedMultiplier,
|
||||
'size' => 28.0,
|
||||
'health' => 3,
|
||||
'knockback' => 240.0,
|
||||
'scoreValue' => 6,
|
||||
'color' => [0.15, 0.25, 0.75, 1.0],
|
||||
'dashRange' => 300.0,
|
||||
'windupTime' => 0.35,
|
||||
'dashTime' => 0.28,
|
||||
'dashSpeed' => 680.0,
|
||||
'dashCooldown' => 1.8,
|
||||
'emergeTime' => $emergeTime,
|
||||
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
|
||||
$room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.15, 0.25, 0.75, 1.0], 'scale' => 1.2]);
|
||||
}
|
||||
|
||||
public function spawnBoss(Room $room): void
|
||||
{
|
||||
$width = $room->width;
|
||||
$witchCount = min(4, 2 ** $room->bossCount);
|
||||
$room->bossCount += 1;
|
||||
$room->entities->append(new Boss($width / 2.0, -80.0, $room->entities, $room, $this->initialTarget($room), [
|
||||
'speed' => 36.0 + $this->rand() * 10.0,
|
||||
'size' => 90.0,
|
||||
'health' => 80,
|
||||
'knockback' => 40.0,
|
||||
'scoreValue' => 50,
|
||||
'color' => [0.45, 0.05, 0.1, 1.0],
|
||||
'spawnInterval' => 5.0,
|
||||
'spawnCount' => $witchCount,
|
||||
'spawnZombie' => function (float $sx, float $sy, EntityList $container, Room $sroom, ?Player $starget): void {
|
||||
if ($this->chance(0.5)) {
|
||||
$container->append(new Witch($sx, $sy, $container, $sroom, $starget, [
|
||||
'speed' => (70.0 + $this->rand() * 40.0) * $this->speedMultiplier($sroom->elapsed),
|
||||
'size' => 30.0,
|
||||
'health' => 5,
|
||||
'knockback' => 180.0,
|
||||
'scoreValue' => 4,
|
||||
'color' => [0.55, 0.2, 0.8, 1.0],
|
||||
'preferredDistance' => 220.0,
|
||||
'fireInterval' => 2.2,
|
||||
'projectileSpeed' => 220.0,
|
||||
'emergeTime' => 0.9,
|
||||
], fn (Zombie $z) => $z->target = $starget));
|
||||
} else {
|
||||
$container->append(new Leaper($sx, $sy, $container, $sroom, $starget, [
|
||||
'speed' => (80.0 + $this->rand() * 40.0) * $this->speedMultiplier($sroom->elapsed),
|
||||
'size' => 28.0,
|
||||
'health' => 3,
|
||||
'knockback' => 240.0,
|
||||
'scoreValue' => 6,
|
||||
'color' => [0.15, 0.25, 0.75, 1.0],
|
||||
'dashRange' => 300.0,
|
||||
'windupTime' => 0.35,
|
||||
'dashTime' => 0.28,
|
||||
'dashSpeed' => 680.0,
|
||||
'dashCooldown' => 1.8,
|
||||
'emergeTime' => 0.9,
|
||||
], fn (Zombie $z) => $z->target = $starget));
|
||||
}
|
||||
},
|
||||
], fn (Zombie $z) => $z->target = $this->initialTarget($room)));
|
||||
$room->pushEvent('portal', ['x' => $width / 2.0, 'y' => -80.0, 'color' => [0.45, 0.05, 0.1, 1.0], 'scale' => 3.5]);
|
||||
$room->pushEvent('boss', [
|
||||
'x' => $width / 2.0,
|
||||
'y' => $room->height / 2.0,
|
||||
'title' => 'BOSS APPROACHES',
|
||||
]);
|
||||
}
|
||||
|
||||
public function spawnPickup(Room $room): void
|
||||
{
|
||||
$available = [];
|
||||
foreach (array_keys(Pickup::TYPES) as $type) {
|
||||
$needed = false;
|
||||
foreach ($room->alivePlayers() as $player) {
|
||||
if (($player->upgrades[$type] ?? 0) < $player->maxUpgradeLevel) {
|
||||
$needed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($needed) {
|
||||
$available[] = $type;
|
||||
}
|
||||
}
|
||||
if ($available === []) {
|
||||
return;
|
||||
}
|
||||
$margin = 60.0;
|
||||
$x = $margin + $this->rand() * ($room->width - $margin * 2.0);
|
||||
$y = $margin + $this->rand() * ($room->height - $margin * 2.0);
|
||||
$type = $available[(int) floor($this->rand() * count($available))];
|
||||
$room->entities->append(new Pickup($x, $y, $type, $room->entities, $room));
|
||||
}
|
||||
|
||||
/** @return array{0:float, 1:float} */
|
||||
private function edgePosition(Room $room, float $size): array
|
||||
{
|
||||
$width = $room->width;
|
||||
$height = $room->height;
|
||||
$edge = (int) floor($this->rand() * 4.0);
|
||||
|
||||
return match ($edge) {
|
||||
0 => [$this->rand() * $width, -$size],
|
||||
1 => [$this->rand() * $width, $height + $size],
|
||||
2 => [-$size, $this->rand() * $height],
|
||||
default => [$width + $size, $this->rand() * $height],
|
||||
};
|
||||
}
|
||||
|
||||
private function initialTarget(Room $room): ?Player
|
||||
{
|
||||
$alive = $room->alivePlayers();
|
||||
|
||||
return $alive === [] ? null : $alive[array_rand($alive)];
|
||||
}
|
||||
|
||||
private function chance(float $probability): bool
|
||||
{
|
||||
return $this->rand() < $probability;
|
||||
}
|
||||
|
||||
private function rand(): float
|
||||
{
|
||||
return random_int(0, 1000000) / 1000000.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game;
|
||||
|
||||
final class Vector2
|
||||
{
|
||||
public function __construct(
|
||||
public float $x,
|
||||
public float $y,
|
||||
) {}
|
||||
|
||||
public static function zero(): self
|
||||
{
|
||||
return new self(0.0, 0.0);
|
||||
}
|
||||
|
||||
public function mulS(float $scalar): self
|
||||
{
|
||||
return new self($this->x * $scalar, $this->y * $scalar);
|
||||
}
|
||||
|
||||
public function mulV(self $v): self
|
||||
{
|
||||
return new self($this->x * $v->x, $this->y * $v->y);
|
||||
}
|
||||
|
||||
public function divS(float $scalar): self
|
||||
{
|
||||
return new self($this->x / $scalar, $this->y / $scalar);
|
||||
}
|
||||
|
||||
public function addS(float $scalar): self
|
||||
{
|
||||
return new self($this->x + $scalar, $this->y + $scalar);
|
||||
}
|
||||
|
||||
public function addV(self $v): self
|
||||
{
|
||||
return new self($this->x + $v->x, $this->y + $v->y);
|
||||
}
|
||||
|
||||
public function subS(float $scalar): self
|
||||
{
|
||||
return new self($this->x - $scalar, $this->y - $scalar);
|
||||
}
|
||||
|
||||
public function subV(self $v): self
|
||||
{
|
||||
return new self($this->x - $v->x, $this->y - $v->y);
|
||||
}
|
||||
|
||||
public function dot(self $v): float
|
||||
{
|
||||
return $this->x * $v->x + $this->y * $v->y;
|
||||
}
|
||||
|
||||
public function abs(): self
|
||||
{
|
||||
return new self(abs($this->x), abs($this->y));
|
||||
}
|
||||
|
||||
public function length(): float
|
||||
{
|
||||
return sqrt($this->dot($this));
|
||||
}
|
||||
|
||||
public function lengthSquared(): float
|
||||
{
|
||||
return $this->dot($this);
|
||||
}
|
||||
|
||||
public function normalize(): self
|
||||
{
|
||||
$length = $this->length();
|
||||
if ($length < 1.0e-9) {
|
||||
return new self(0.0, 0.0);
|
||||
}
|
||||
|
||||
return new self($this->x / $length, $this->y / $length);
|
||||
}
|
||||
|
||||
public function rotate(float $angle): self
|
||||
{
|
||||
$cos = cos($angle);
|
||||
$sin = sin($angle);
|
||||
|
||||
return new self($this->x * $cos - $this->y * $sin, $this->x * $sin + $this->y * $cos);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user