diff --git a/cli/server.php b/cli/server.php index 1933858..7741ec8 100644 --- a/cli/server.php +++ b/cli/server.php @@ -16,14 +16,17 @@ $bindAddress = "udp://0.0.0.0:$port"; ///// +$settings = new ServerSetting($playersMax); // must be first for correctly setting the global tickRate (Util::$TICK_RATE) + $logger = new ConsoleLogger(); -$settings = new ServerSetting($playersMax); -$logger->info("Starting server on '{$bindAddress}', waiting maximum of '{$settings->warmupWaitSec}' sec for '{$playersMax}' player" . ($playersMax > 1 ? 's' : '') . " to connect."); -$net = new ClueSocket($bindAddress); +$logger->info("Preparing game for launch."); $game = ($debug ? GameFactory::createDebug() : GameFactory::createDefaultCompetitive()); $game->loadMap(new Maps\DefaultMap()); +$game->getWorld()->regenerateNavigationMeshes(); +$logger->info("Starting server on '{$bindAddress}', waiting maximum of '{$settings->warmupWaitSec}' sec for '{$playersMax}' player" . ($playersMax > 1 ? 's' : '') . " to connect."); +$net = new ClueSocket($bindAddress); $server = new Server($game, $settings, $net); $server->setLogger($logger); if ($debug) { diff --git a/composer.json b/composer.json index 114a577..157f3e4 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "php": ">=8.1", "ext-sockets": "*", + "actived/graphphp": "0.2.2", "clue/socket-raw": "1.6.0", "textalk/websocket": "1.5.8", "psr/log": "3.0.0" diff --git a/composer.lock b/composer.lock index 6f40dbe..cc660ef 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,55 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1d2bad3250abdd8d557a0c90a02b2598", + "content-hash": "755697a61c7c4bb0a8359326d7550df3", "packages": [ + { + "name": "actived/graphphp", + "version": "0.2.2", + "source": { + "type": "git", + "url": "https://github.com/ActiveDevOrga/graphPHP.git", + "reference": "2a064c45d8864bf00a546e0e24cc9a40f182e4dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ActiveDevOrga/graphPHP/zipball/2a064c45d8864bf00a546e0e24cc9a40f182e4dc", + "reference": "2a064c45d8864bf00a546e0e24cc9a40f182e4dc", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphPHP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "ActiveDeveloppment", + "homepage": "https://actived.fr/" + }, + { + "name": "Houssem Guemer", + "email": "houssem.guemer@gmail.com" + } + ], + "description": "A PHP graph theory package.", + "support": { + "issues": "https://github.com/ActiveDevOrga/graphphp/issues", + "source": "https://github.com/ActiveDevOrga/graphphp" + }, + "time": "2023-10-01T09:53:53+00:00" + }, { "name": "clue/socket-raw", "version": "v1.6.0", diff --git a/phpunit.xml b/phpunit.xml index b63cce7..8bbc7b5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,7 +6,6 @@ stderr="true" colors="true" cacheResult="false" - stopOnFailure="true" executionOrder="random" > diff --git a/server/src/Core/Flame.php b/server/src/Core/Flame.php new file mode 100644 index 0000000..b1ba839 --- /dev/null +++ b/server/src/Core/Flame.php @@ -0,0 +1,15 @@ +highestPoint = $this->center->clone()->addY($this->height); + } + +} diff --git a/server/src/Core/Game.php b/server/src/Core/Game.php index 62c4257..c10806f 100644 --- a/server/src/Core/Game.php +++ b/server/src/Core/Game.php @@ -8,9 +8,11 @@ use cs\Enum\RoundEndReason; use cs\Enum\SoundType; use cs\Equipment\Bomb; +use cs\Equipment\Grenade; use cs\Event\DropEvent; use cs\Event\Event; use cs\Event\GameOverEvent; +use cs\Event\GrillEvent; use cs\Event\KillEvent; use cs\Event\PauseEndEvent; use cs\Event\PauseStartEvent; @@ -251,6 +253,11 @@ public function addThrowEvent(ThrowEvent $event): void $this->addEvent($event); } + public function addGrillEvent(GrillEvent $event): void + { + $this->addEvent($event); + } + public function addDropEvent(DropEvent $event): void { $this->addEvent($event); @@ -258,7 +265,21 @@ public function addDropEvent(DropEvent $event): void public function playerAttackKilledEvent(Player $playerDead, Bullet $bullet, bool $headShot): void { - $playerCulprit = $this->players[$bullet->getOriginPlayerId()]; + $this->playerKilledEvent($this->players[$bullet->getOriginPlayerId()], $playerDead, $bullet->getShootItem()->getId(), $headShot); + } + + public function playerGrenadeKilledEvent(Player $playerCulprit, Player $playerDead, Grenade $item): void + { + $moneyAward = $item->getKillAward(); + if ($playerCulprit->isPlayingOnAttackerSide() === $playerDead->isPlayingOnAttackerSide()) { + $moneyAward = -300; + } + $playerCulprit->getInventory()->earnMoney($moneyAward); + $this->playerKilledEvent($playerCulprit, $playerDead, $item->getId(), false); + } + + private function playerKilledEvent(Player $playerCulprit, Player $playerDead, int $itemId, bool $headShot): void + { if ($playerDead->isPlayingOnAttackerSide() === $playerCulprit->isPlayingOnAttackerSide()) { // team kill $this->score->getPlayerStat($playerCulprit->getId())->removeKill(); } else { @@ -266,7 +287,7 @@ public function playerAttackKilledEvent(Player $playerDead, Bullet $bullet, bool } $this->score->getPlayerStat($playerDead->getId())->addDeath(); - $this->addEvent(new KillEvent($playerDead, $playerCulprit, $bullet->getShootItem()->getId(), $headShot)); + $this->addEvent(new KillEvent($playerDead, $playerCulprit, $itemId, $headShot)); $sound = new SoundEvent($playerDead->getPositionClone(), SoundType::PLAYER_DEAD); $this->addSoundEvent($sound->setPlayer($playerDead)); } diff --git a/server/src/Core/Graph.php b/server/src/Core/Graph.php new file mode 100644 index 0000000..52a1790 --- /dev/null +++ b/server/src/Core/Graph.php @@ -0,0 +1,20 @@ +nodes); + } + + public function getEdgeCount(): int + { + return count($this->edges); + } + +} diff --git a/server/src/Core/PathFinder.php b/server/src/Core/PathFinder.php new file mode 100644 index 0000000..1d6e420 --- /dev/null +++ b/server/src/Core/PathFinder.php @@ -0,0 +1,195 @@ + */ + private array $visited = []; + /** @var array */ + private readonly array $moves; + private readonly int $obstacleOvercomeHeight; + private int $iterationCount = 0; + public readonly int $tileSizeHalf; + + public function __construct(private readonly World $world, public readonly int $tileSize, public readonly int $colliderHeight) + { + if ($this->tileSize < 3 || $tileSize % 2 !== 1) { + throw new GameException('Tile size should be odd and greater than 1.'); + } + + $this->tileSizeHalf = (int)ceil(($this->tileSize - 1) / 2); + $this->moves = [ + 90 => [+1, +0, +0], + 0 => [+0, +0, +1], + 270 => [-1, +0, +0], + 180 => [+0, +0, -1], + ]; + $this->graph = new Graph(); + $this->obstacleOvercomeHeight = Setting::playerObstacleOvercomeHeight(); + } + + protected function canFullyMoveTo(Point $candidate, int $angle, int $targetDistance, int $radius, int $height): bool + { + if ($angle % 90 !== 0) { + GameException::notImplementedYet(); + } + + $looseFloor = false; + for ($distance = 1; $distance <= $targetDistance; $distance++) { + $candidate->addPart(...$this->moves[$angle]); + if (!$this->canMoveTo($candidate, $angle, $radius)) { + return false; + } + + if (!$looseFloor && !$this->world->findFloorSquare($candidate, $radius)) { + $looseFloor = true; + } + } + + if (!$looseFloor) { + return true; + } + + $fallCandidate = $candidate->clone(); + foreach (range(1, 3 * $height) as $i) { + $fallCandidate->addY(-1); + if ($this->world->findFloorSquare($fallCandidate, $radius)) { + $candidate->setY($fallCandidate->y); + return true; + } + } + + return false; + } + + private function canMoveTo(Point $start, int $angle, int $radius): bool + { + $maxWallCeiling = $start->y + $this->obstacleOvercomeHeight; + $xWallMaxHeight = 0; + if ($angle === 90 || $angle === 270) { + $baseX = $start->clone()->addX(($angle === 90) ? $radius : -$radius); + $xWallMaxHeight = $this->world->findHighestWall($baseX, $this->colliderHeight, $radius, $maxWallCeiling, true); + } + $zWallMaxHeight = 0; + if ($angle === 0 || $angle === 180) { + $baseZ = $start->clone()->addZ(($angle === 0) ? $radius : -$radius); + $zWallMaxHeight = $this->world->findHighestWall($baseZ, $this->colliderHeight, $radius, $maxWallCeiling, false); + } + if ($xWallMaxHeight === 0 && $zWallMaxHeight === 0) { // no walls + return true; + } + + // Try step over ONE low height wall + $highestWallCeiling = null; + if ($xWallMaxHeight === 0 && $zWallMaxHeight <= $maxWallCeiling) { + $highestWallCeiling = $zWallMaxHeight; + } elseif ($zWallMaxHeight === 0 && $xWallMaxHeight <= $maxWallCeiling) { + $highestWallCeiling = $xWallMaxHeight; + } + if ($highestWallCeiling === null) { + return false; + } + + $floor = $this->world->findFloor($start->clone()->setY($highestWallCeiling), $radius); + if ($floor) { + $start->setY($floor->getY()); // side effect + return true; + } + + return false; + } + + public function tryFindClosestTile(Point $point): ?Point + { + $candidate = $point->clone(); + foreach ($this->moves as $angle => $move) { + $candidate->setFrom($point); + + if ($this->canFullyMoveTo($candidate, $angle, $this->tileSize, 0, $this->colliderHeight)) { + $this->convertToNavMeshNode($candidate); + if ($this->getGraph()->getNodeById($candidate->hash())) { + return $candidate; + } + } + } + + return null; + } + + public function convertToNavMeshNode(Point $point): float + { + if ($point->x < 1 || $point->z < 1) { + throw new GameException('World start from 1'); + } + + $fmodX = fmod($point->x, $this->tileSize); + $fmodZ = fmod($point->z, $this->tileSize); + + $x = ((int)floor(($point->x + ($fmodX == 0 ? -1 : +0)) / $this->tileSize) * $this->tileSize) + 1 + $this->tileSizeHalf; + $point->setX($x); + $z = ((int)floor(($point->z + ($fmodZ == 0 ? -1 : +0)) / $this->tileSize) * $this->tileSize) + 1 + $this->tileSizeHalf; + $point->setZ($z); + + return (abs($this->tileSizeHalf - $fmodX) + abs($this->tileSizeHalf - $fmodZ)) / 2; + } + + public function buildNavigationMesh(Point $start, int $objectHeight): void + { + $startPoint = $start->clone(); + $this->convertToNavMeshNode($startPoint); + if (!$this->world->findFloorSquare($startPoint, 1)) { + throw new GameException('No floor on start point'); + } + + /** @var SplQueue $queue */ + $queue = new SplQueue(); + $queue->enqueue($startPoint); + $candidate = new Point(); + while (!$queue->isEmpty()) { + $current = $queue->dequeue(); + $currentKey = $current->hash(); + if (array_key_exists($currentKey, $this->visited)) { + continue; + } + $this->visited[$currentKey] = true; + $currentNodeOrNull = $this->graph->getNodeById($currentKey); + $currentNode = $currentNodeOrNull ?? new Node($currentKey, $current); + + $hasNeighbour = false; + foreach ($this->moves as $angle => $move) { + $candidate->setFrom($current); + if (!$this->canFullyMoveTo($candidate, $angle, $this->tileSize, $this->tileSizeHalf, $objectHeight)) { + continue; + } + + $hasNeighbour = true; + $newNeighbour = $candidate->clone(); + $newNode = $this->graph->getNodeById($newNeighbour->hash()); + if ($newNode === null) { + $newNode = new Node($newNeighbour->hash(), $newNeighbour); + $this->graph->addNode($newNode); + } + $this->graph->addEdge(new DirectedEdge($currentNode, $newNode, 1)); + $queue->enqueue($newNeighbour); + } + if ($hasNeighbour && $currentNodeOrNull === null) { + $this->graph->addNode($currentNode); + } + if (++$this->iterationCount === 10_000) { + GameException::notImplementedYet('New map or bad test (no boundary box, bad starting point)?'); + } + } + } + + public function getGraph(): Graph + { + return $this->graph; + } + +} diff --git a/server/src/Core/Player.php b/server/src/Core/Player.php index 38b3935..e8e1e76 100644 --- a/server/src/Core/Player.php +++ b/server/src/Core/Player.php @@ -271,6 +271,27 @@ public function getHeadFloor(): Floor return $this->headFloor; } + public function getCentrePoint(): Point + { + return $this->getPositionClone()->addY((int) ceil($this->headHeight / 2)); + } + + /** + * @return list + */ + public function getPlayerGrenadeHitPoints(): array + { + $output = []; + $hitPointsCount = 5; + $offset = max(1, (int) floor(($this->headHeight - 8) / ($hitPointsCount - 1))); + $candidate = $this->getPositionClone()->addY(4); + foreach (range(0, $hitPointsCount - 1) as $i) { + $output[] = $candidate->clone()->addY($i * $offset); + } + + return $output; + } + /** * @param array{id: int, color: int, isAttacker: bool} $data */ diff --git a/server/src/Core/PlayerCollider.php b/server/src/Core/PlayerCollider.php index 0883928..e372c7c 100644 --- a/server/src/Core/PlayerCollider.php +++ b/server/src/Core/PlayerCollider.php @@ -78,4 +78,9 @@ public function getHitBoxes(): array return $this->hitBoxes; } + public function getPlayer(): Player + { + return $this->player; + } + } diff --git a/server/src/Core/Point.php b/server/src/Core/Point.php index cec92bc..f9e799a 100644 --- a/server/src/Core/Point.php +++ b/server/src/Core/Point.php @@ -111,6 +111,11 @@ public function addFromArray(array $xyz): void $this->z += $xyz[2]; } + public function hash(): string + { + return "{$this->x},{$this->y},{$this->z}"; + } + public function to2D(string $XYaxis): Point2D { return new Point2D($this->{$XYaxis[0]}, $this->{$XYaxis[1]}); diff --git a/server/src/Core/World.php b/server/src/Core/World.php index 3335d96..ab8aaae 100644 --- a/server/src/Core/World.php +++ b/server/src/Core/World.php @@ -2,12 +2,17 @@ namespace cs\Core; +use cs\Enum\ArmorType; use cs\Enum\InventorySlot; use cs\Enum\SoundType; use cs\Equipment\Bomb; +use cs\Equipment\Grenade; +use cs\Equipment\HighExplosive; use cs\Event\DropEvent; +use cs\Event\GrillEvent; use cs\Event\SoundEvent; use cs\Event\ThrowEvent; +use cs\Interface\Flammable; use cs\Interface\Hittable; use cs\Map\Map; @@ -18,6 +23,8 @@ class World private const BOMB_RADIUS = 90; private const BOMB_DEFUSE_MAX_DISTANCE = 300; private const ITEM_PICK_MAX_DISTANCE = 370; + public const GRENADE_NAVIGATION_MESH_TILE_SIZE = 31; + public const GRENADE_NAVIGATION_MESH_OBJECT_HEIGHT = 80; private ?Map $map = null; /** @var PlayerCollider[] */ @@ -36,6 +43,7 @@ class World private int $lastBombActionTick = -1; private int $lastBombPlayerId = -1; private int $playerPotentialDistanceSquared; + private ?PathFinder $grenadeNavMesh = null; public function __construct(private Game $game) { @@ -69,6 +77,13 @@ public function loadMap(Map $map): void } } + public function regenerateNavigationMeshes(): void + { + $key = sprintf('%d-%d', self::GRENADE_NAVIGATION_MESH_TILE_SIZE, self::GRENADE_NAVIGATION_MESH_OBJECT_HEIGHT); + $this->grenadeNavMesh = $this->getMap()->getNavigationMesh($key) + ?? $this->buildNavigationMesh(self::GRENADE_NAVIGATION_MESH_TILE_SIZE, self::GRENADE_NAVIGATION_MESH_OBJECT_HEIGHT); + } + public function addRamp(Ramp $ramp): void { foreach ($ramp->getBoxes() as $box) { @@ -174,6 +189,39 @@ public function findFloor(Point $point, int $radius = 0): ?Floor return $targetFloor; } + public function findHighestWall(Point $bottomCenter, int $height, int $radius, int $maxWallCeiling, bool $xWall): int + { + $base = $xWall ? $bottomCenter->x : $bottomCenter->z; + if ($base < 0) { + return $maxWallCeiling + 1; + } + $walls = $xWall ? $this->getXWalls($base) : $this->getZWalls($base); + if ($walls === []) { + return 0; + } + + $width = 2 * $radius; + $highestWallCeiling = 0; + $candidatePlane = $bottomCenter->to2D($xWall ? 'zy' : 'xy')->addX(-$radius); + foreach ($walls as $wall) { + $wallCeiling = $wall->getCeiling(); + if ($wallCeiling <= $bottomCenter->y) { + continue; + } + if (!Collision::planeWithPlane($wall->getPoint2DStart(), $wall->width, $wall->height, $candidatePlane, $width, $height)) { + continue; + } + if ($wallCeiling > $maxWallCeiling) { + return $wallCeiling; + } + if ($wallCeiling > $highestWallCeiling) { + $highestWallCeiling = $wallCeiling; + } + } + + return $highestWallCeiling; + } + public function isOnFloor(Floor $floor, Point $position, int $radius): bool { return ( @@ -303,14 +351,23 @@ public function canBeSeen(Player $observer, Point $targetCenter, int $targetRadi if (Util::distanceSquared($start, $targetCenter) > $maximumDistance * $maximumDistance) { return false; } - $angleVertical = $observer->getSight()->getRotationVertical(); - $angleHorizontal = $observer->getSight()->getRotationHorizontal(); - $prevPos = $start->clone(); - $candidate = $start->clone(); - for ($distance = $observer->getBoundingRadius(); $distance <= $maximumDistance; $distance++) { + return $this->pointCanSeePoint( + $start, $targetCenter, $observer->getSight()->getRotationHorizontal(), $observer->getSight()->getRotationVertical(), + $maximumDistance, $checkForOtherPlayersAlso ? $observer->getId() : null, $targetRadius, $observer->getBoundingRadius(), + ); + } + + private function pointCanSeePoint( + Point $observer, Point $targetCenter, float $angleHorizontal, float $angleVertical, + int $maximumDistance, int|null $playerIdSkip = -1, int $targetRadius = 1, int $startDistance = 0, + ): bool + { + $prevPos = $observer->clone(); + $candidate = $observer->clone(); + for ($distance = $startDistance; $distance <= $maximumDistance; $distance++) { [$x, $y, $z] = Util::movementXYZ($angleHorizontal, $angleVertical, $distance); - $candidate->set($start->x + $x, $start->y + $y, $start->z + $z); + $candidate->set($observer->x + $x, $observer->y + $y, $observer->z + $z); if ($candidate->equals($prevPos)) { continue; } @@ -325,7 +382,7 @@ public function canBeSeen(Player $observer, Point $targetCenter, int $targetRadi if ($this->isWallAt($candidate)) { return false; } - if ($checkForOtherPlayersAlso && $this->isCollisionWithOtherPlayers($observer->getId(), $candidate, 0, 0)) { + if ($playerIdSkip !== null && $this->isCollisionWithOtherPlayers($playerIdSkip, $candidate, 0, 0)) { return false; } } @@ -412,9 +469,110 @@ public function makeSound(SoundEvent $soundEvent): void public function throw(ThrowEvent $event): void { + $event->onComplete[] = function (ThrowEvent $event) { + if ($event->item instanceof HighExplosive) { + $this->processHighExplosiveBlast($event->getPlayer(), $event->getPositionClone(), $event->item); + } + if ($event->item instanceof Flammable) { + $this->processFlammableExplosion($event->getPlayer(), $event->getPositionClone(), $event->item); + } + }; $this->game->addThrowEvent($event); } + public function processFlammableExplosion(Player $thrower, Point $epicentre, Flammable $item): void + { + if ($this->grenadeNavMesh === null) { + $this->regenerateNavigationMeshes(); + } + assert($this->grenadeNavMesh !== null); + + $epicentreFloor = $epicentre->clone()->addY(-$item->getBoundingRadius()); + $floorNavmeshPoint = $epicentreFloor->clone(); + $this->grenadeNavMesh->convertToNavMeshNode($floorNavmeshPoint); + + if (null === $this->grenadeNavMesh->getGraph()->getNodeById($floorNavmeshPoint->hash())) { + $floorNavmeshPoint = $this->grenadeNavMesh->tryFindClosestTile($epicentreFloor); + if (null === $floorNavmeshPoint) { + return; + } + } + + $this->game->addGrillEvent( + new GrillEvent( + $thrower, $item, $this, $this->grenadeNavMesh->tileSizeHalf, + $this->grenadeNavMesh->colliderHeight, $this->grenadeNavMesh->getGraph(), $floorNavmeshPoint, + ) + ); + } + + public function checkFlameDamage(GrillEvent $fire, int $tickId): void + { + foreach ($this->playersColliders as $playerId => $collider) { + $player = $collider->getPlayer(); + if (!$player->isAlive() || !$fire->canHitPlayer($playerId, $tickId)) { + continue; + } + + foreach ($fire->flames as $flame) { + if (!Collision::pointWithCylinder( + $flame->highestPoint, + $player->getReferenceToPosition(), + $player->getBoundingRadius(), + $player->getHeadHeight()) + ) { + continue; + } + + $fire->playerHit($playerId, $tickId); + $damage = $fire->item->calculateDamage($player->getArmorType() !== ArmorType::NONE); + if ($fire->initiator->isPlayingOnAttackerSide() !== $player->isPlayingOnAttackerSide()) { + $this->game->getScore()->getPlayerStat($fire->initiator->getId())->addDamage($damage); + } + $player->lowerHealth($damage); + if (!$player->isAlive()) { + $this->playerDiedToFlame($fire->initiator, $player, $fire->item); + } + + break; + } + } + } + + private function processHighExplosiveBlast(Player $thrower, Point $epicentre, HighExplosive $item): void + { + $maxBlastDistance = $item->getMaxBlastRadius(); + $maxBlastDistanceSquared = $maxBlastDistance * $maxBlastDistance; + foreach ($this->playersColliders as $playerId => $playerCollider) { + $player = $this->game->getPlayer($playerId); + if (!$player->isAlive()) { + continue; + } + if (Util::distanceSquared($epicentre, $player->getCentrePoint()) > $maxBlastDistanceSquared) { + continue; + } + + $damage = 0; + foreach ($player->getPlayerGrenadeHitPoints() as $point) { + $distanceSquared = Util::distanceSquared($epicentre, $point); + if ($distanceSquared > $maxBlastDistanceSquared) { + continue; + } + [$angleHorizontal, $angleVertical] = Util::worldAngle($point, $epicentre); + if (!$this->pointCanSeePoint($epicentre, $point, $angleHorizontal ?? 0, $angleVertical, $maxBlastDistance, null)) { + continue; + } + + $damage += $item->calculateDamage($distanceSquared, $player->getArmorType() !== ArmorType::NONE); + } + + $player->lowerHealth($damage); + if (!$player->isAlive()) { + $this->game->playerGrenadeKilledEvent($thrower, $player, $item); + } + } + } + public function canAttack(Player $player): bool { if ($this->game->isPaused()) { @@ -464,6 +622,33 @@ public function playerDiedToFallDamage(Player $playerDead): void $this->game->playerFallDamageKilledEvent($playerDead); } + public function playerDiedToFlame(Player $playerCulprit, Player $playerDead, Flammable $item): void + { + if (false === ($item instanceof Grenade)) { + throw new GameException("New flammable non grenade type?"); + } + $this->game->playerGrenadeKilledEvent($playerCulprit, $playerDead, $item); + } + + public function buildNavigationMesh(int $tileSize, int $objectHeight): PathFinder + { + $boundingRadius = Setting::playerBoundingRadius(); + if ($tileSize > $boundingRadius - 2) { + throw new GameException('Tile size should be decently lower than player bounding radius.'); + } + + $pathFinder = new PathFinder($this, $tileSize, $objectHeight); + $startPoints = $this->getMap()->getStartingPointsForNavigationMesh(); + if ([] === $startPoints) { + throw new GameException('No starting point for navigation defined!'); + } + foreach ($startPoints as $point) { + $pathFinder->buildNavigationMesh($point, $objectHeight); + } + + return $pathFinder; + } + public function checkXSideWallCollision(Point $bottomCenter, int $height, int $radius): ?Wall { $candidatePlane = $bottomCenter->to2D(self::WALL_X)->addX(-$radius); diff --git a/server/src/Enum/SoundType.php b/server/src/Enum/SoundType.php index 8699a5e..2002178 100644 --- a/server/src/Enum/SoundType.php +++ b/server/src/Enum/SoundType.php @@ -9,7 +9,7 @@ enum SoundType: int case PLAYER_STEP = 1; case ITEM_ATTACK = 2; case ITEM_ATTACK2 = 3; - // + case FLAME_SPAWN = 4; case ITEM_BUY = 5; case BULLET_HIT = 6; case PLAYER_DEAD = 7; @@ -27,5 +27,6 @@ enum SoundType: int case GRENADE_AIR = 19; case ITEM_DROP_AIR = 20; case ITEM_DROP_LAND = 21; + case FLAME_EXTINGUISH = 22; } diff --git a/server/src/Equipment/Grenade.php b/server/src/Equipment/Grenade.php index d0c18a7..086b8e1 100644 --- a/server/src/Equipment/Grenade.php +++ b/server/src/Equipment/Grenade.php @@ -2,6 +2,7 @@ namespace cs\Equipment; +use cs\Core\GameException; use cs\Enum\ArmorType; use cs\Enum\HitBoxType; use cs\Enum\ItemType; @@ -35,12 +36,12 @@ public function attackSecondary(Attackable $event): ?AttackResult public function getDamageValue(HitBoxType $hitBox, ArmorType $armor): int { - return 0; + GameException::notImplementedYet('Should not be called'); } public function getKillAward(): int { - return 0; + return 300; } public function getBoundingRadius(): int diff --git a/server/src/Equipment/HighExplosive.php b/server/src/Equipment/HighExplosive.php index 8aee648..aeabf91 100644 --- a/server/src/Equipment/HighExplosive.php +++ b/server/src/Equipment/HighExplosive.php @@ -7,6 +7,10 @@ class HighExplosive extends Grenade { + protected const DAMAGE = 20; + protected const MAX_BLAST_RADIUS = 400; + protected const MAX_BLAST_RADIUS_SQUARED = self::MAX_BLAST_RADIUS * self::MAX_BLAST_RADIUS; + protected int $price = 300; public function getSlot(): InventorySlot @@ -14,5 +18,20 @@ public function getSlot(): InventorySlot return InventorySlot::SLOT_GRENADE_HE; } + public function getMaxBlastRadius(): int + { + return self::MAX_BLAST_RADIUS; + } + + public function calculateDamage(int $distanceSquared, bool $harArmor): int + { + $distanceSquared = max(1, $distanceSquared); + if ($distanceSquared >= self::MAX_BLAST_RADIUS_SQUARED) { + return 0; + } + + $damage = self::DAMAGE * (1 - ($distanceSquared / self::MAX_BLAST_RADIUS_SQUARED)); + return (int) ceil($harArmor ? $damage * 0.3 : $damage); + } } diff --git a/server/src/Equipment/Incendiary.php b/server/src/Equipment/Incendiary.php index 7e94997..8e21803 100644 --- a/server/src/Equipment/Incendiary.php +++ b/server/src/Equipment/Incendiary.php @@ -3,9 +3,11 @@ namespace cs\Equipment; use cs\Enum\InventorySlot; +use cs\Interface\Flammable; -class Incendiary extends Grenade +class Incendiary extends Grenade implements Flammable { + public const MAX_TIME_MS = 7_000; protected int $price = 600; @@ -14,5 +16,24 @@ public function getSlot(): InventorySlot return InventorySlot::SLOT_GRENADE_MOLOTOV; } + public function getMaxTimeMs(): int + { + return self::MAX_TIME_MS; + } + + public function getSpawnAreaMetersSquared(): int + { + return 90; + } + + public function getMaxAreaMetersSquared(): int + { + return 200_000; + } + + public function calculateDamage(bool $hasKevlar): int + { + return $hasKevlar ? 7 : 3; + } } diff --git a/server/src/Equipment/Molotov.php b/server/src/Equipment/Molotov.php index bcebb6b..1b95fe6 100644 --- a/server/src/Equipment/Molotov.php +++ b/server/src/Equipment/Molotov.php @@ -3,9 +3,11 @@ namespace cs\Equipment; use cs\Enum\InventorySlot; +use cs\Interface\Flammable; -class Molotov extends Grenade +class Molotov extends Grenade implements Flammable { + public const MAX_TIME_MS = 7_000; protected int $price = 400; @@ -14,5 +16,24 @@ public function getSlot(): InventorySlot return InventorySlot::SLOT_GRENADE_MOLOTOV; } + public function getMaxTimeMs(): int + { + return self::MAX_TIME_MS; + } + + public function getSpawnAreaMetersSquared(): int + { + return 100; + } + + public function getMaxAreaMetersSquared(): int + { + return 200_000; + } + + public function calculateDamage(bool $hasKevlar): int + { + return $hasKevlar ? 8 : 4; + } } diff --git a/server/src/Event/EventList.php b/server/src/Event/EventList.php index 54e4903..1a2e3a1 100644 --- a/server/src/Event/EventList.php +++ b/server/src/Event/EventList.php @@ -19,6 +19,7 @@ final class EventList PlantEvent::class => 10, ThrowEvent::class => 11, DropEvent::class => 12, + GrillEvent::class => 13, ]; } diff --git a/server/src/Event/GrillEvent.php b/server/src/Event/GrillEvent.php new file mode 100644 index 0000000..e0baea5 --- /dev/null +++ b/server/src/Event/GrillEvent.php @@ -0,0 +1,149 @@ + */ + public array $flames = []; + private int $startedTickId; + private int $lastFlameSpawnTickId; + private int $spawnTickCount; + private int $spawnFlameCount; + private int $maxTicksCount; + /** @var array [playerId => tick] */ + private array $playerTickHits = []; + private readonly int $damageCoolDownTickCount; + private readonly int $maxFlameCount; + + /** @var SplQueue $queue */ + private SplQueue $queue; + /** @var array */ + private array $visited = []; + + public function __construct( + public readonly Player $initiator, + public readonly Flammable $item, + private readonly World $world, + private readonly int $flameRadius, + private readonly int $flameHeight, + private readonly Graph $graph, + Point $start, + ) + { + $flameArea = ($this->flameRadius * 2 + 1) ** 2; + $this->spawnTickCount = Util::millisecondsToFrames(20); + $this->spawnFlameCount = (int)ceil($this->item->getSpawnAreaMetersSquared() / $flameArea); + $this->maxTicksCount = Util::millisecondsToFrames($this->item->getMaxTimeMs()); + $this->damageCoolDownTickCount = Util::millisecondsToFrames(100); + $this->maxFlameCount = (int)ceil($this->item->getMaxAreaMetersSquared() / $flameArea); + $this->startedTickId = $this->world->getTickId(); + + $startNode = $this->graph->getNodeById($start->hash()); + if (null === $startNode) { + throw new GameException("No node for start point: " . $start->hash()); + } + + $this->id = "grill-{$this->initiator->getId()}-{$this->world->getTickId()}"; + $this->queue = new SplQueue(); + $this->queue->enqueue($startNode); + $this->igniteFlames(); + } + + private function extinguish(): void + { + for ($i = 1; $i <= $this->spawnFlameCount; $i++) { + $flame = array_pop($this->flames); + if ($flame === null) { + return; + } + + $sound = new SoundEvent($flame->center, SoundType::FLAME_EXTINGUISH); + $sound->addExtra('fire', $this->id); + $this->world->makeSound($sound); + } + } + + public function process(int $tick): void + { + if ([] === $this->flames) { + $this->runOnCompleteHooks(); + return; + } + if ($tick >= $this->startedTickId + $this->maxTicksCount) { + $this->extinguish(); + $this->world->checkFlameDamage($this, $tick); + return; + } + + if ($tick >= $this->lastFlameSpawnTickId + $this->spawnTickCount) { + $this->igniteFlames(); + } + + $this->world->checkFlameDamage($this, $tick); + } + + private function igniteFlames(): void + { + foreach ($this->loadFlames() as $candidate) { + $flame = new Flame($candidate, $this->flameRadius, $this->flameHeight); + $this->flames[] = $flame; + $this->lastFlameSpawnTickId = $this->world->getTickId(); + $sound = new SoundEvent($flame->center, SoundType::FLAME_SPAWN); + $sound->addExtra('fire', $this->id); + $sound->addExtra('height', $flame->height); + $sound->addExtra('size', $this->flameRadius * 2 + 1); + $this->world->makeSound($sound); + } + } + + /** @return list */ + private function loadFlames(): array + { + $loadCount = $this->maxFlameCount - count($this->flames); + + $output = []; + while (!$this->queue->isEmpty() && count($output) < min($this->spawnFlameCount, $loadCount)) { + $current = $this->queue->dequeue(); + if (array_key_exists($current->getId(), $this->visited)) { + continue; + } + + $this->visited[$current->getId()] = true; + /** @var Point $point */ + $point = $current->getData(); + $output[] = $point; + + foreach ($this->graph->getNeighbors($current) as $node) { + $this->queue->enqueue($node); + } + } + + return $output; + } + + public function canHitPlayer(int $playerId, int $tickId): bool + { + return (($this->playerTickHits[$playerId] ?? 0) + $this->damageCoolDownTickCount <= $tickId); + } + + public function playerHit(int $playerId, int $tickId): void + { + $this->playerTickHits[$playerId] = $tickId; + } + +} diff --git a/server/src/Event/ThrowEvent.php b/server/src/Event/ThrowEvent.php index ba7c754..57d1604 100644 --- a/server/src/Event/ThrowEvent.php +++ b/server/src/Event/ThrowEvent.php @@ -15,8 +15,10 @@ use cs\Equipment\HighExplosive; use cs\HitGeometry\BallCollider; use cs\Interface\Attackable; +use cs\Interface\ForOneRoundMax; +use cs\Interface\Flammable; -final class ThrowEvent extends Event implements Attackable +final class ThrowEvent extends Event implements Attackable, ForOneRoundMax { private string $id; @@ -31,14 +33,14 @@ final class ThrowEvent extends Event implements Attackable private int $tickMax; public function __construct( - private readonly Player $player, - private readonly World $world, - Point $origin, - private readonly Grenade $item, - private float $angleHorizontal, - private float $angleVertical, - public readonly int $radius, - private float $velocity, + private readonly Player $player, + private readonly World $world, + Point $origin, + public readonly Grenade $item, + private float $angleHorizontal, + private float $angleVertical, + public readonly int $radius, + private float $velocity, ) { if ($this->velocity <= 0) { @@ -141,6 +143,11 @@ public function process(int $tick): void continue; } + if ($this->angleVertical < 10 && $this->item instanceof Flammable && $this->ball->getResolutionAngleVertical() > 0) { + $this->finishLanding($pos); + return; + } + $this->setAngles($this->ball->getResolutionAngleHorizontal(), $this->ball->getResolutionAngleVertical()); $this->bounceCount++; $this->velocity = $this->velocity / ($this->bounceCount > 4 ? $this->bounceCount : 1.5); @@ -201,4 +208,14 @@ public function serialize(): array ]; } + public function getPositionClone(): Point + { + return $this->position->clone(); + } + + public function getPlayer(): Player + { + return $this->player; + } + } diff --git a/server/src/Interface/Flammable.php b/server/src/Interface/Flammable.php new file mode 100644 index 0000000..fb68544 --- /dev/null +++ b/server/src/Interface/Flammable.php @@ -0,0 +1,18 @@ +addBox(new Box(new Point(($x - 1) * $scale, $y, 5 * $scale), 3 * $scale, $boxHeight, $scale)); $this->addBox(new Box(new Point(($x) * $scale, $y + $boxHeight, 5 * $scale), 1 * $scale, $boxHeight, $scale)); + $this->navmeshPoints[] = new Point(($x) * $scale + $scaleHalf, 2 * $boxHeight + $y, 5 * $scale + $scaleHalf); $defenders[] = new Point($x * $scale + $scaleHalf, $y, 28 * $scale + $radiusHalf); $this->addBox(new Box(new Point(($x - 1) * $scale, $y, 26 * $scale), 3 * $scale, $boxHeight, $scale)); $this->addBox(new Box(new Point(($x) * $scale, $y + $boxHeight, 26 * $scale), 1 * $scale, $boxHeight, $scale)); + $this->navmeshPoints[] = new Point(($x) * $scale + $scaleHalf, 2 * $boxHeight + $y, 26 * $scale + $scaleHalf); } $stepHeight = 10; @@ -46,6 +50,7 @@ public function __construct() $this->addBox($box); } $this->addBox(new Box(new Point(21 * $scale, $y, $z * $scale), $scale, $heightCrouch, $scale)); + $this->navmeshPoints[] = new Point(21 * $scale + $scaleHalf, $y + $heightCrouch, $z * $scale + $scaleHalf); $ramp2 = new Ramp(new Point(24 * $scale - 20, $y, $z * $scale), new Point2D(-1, 0), $stepCount, $scale, true, 12, $stepHeight); foreach ($ramp2->getBoxes() as $box) { $this->addBox($box); @@ -60,6 +65,11 @@ public function __construct() $this->buyArea[1] = new Box(new Point(0, $y, 0), 43 * $scale, 2 * $heightStand, 6 * $scale); } + public function getStartingPointsForNavigationMesh(): array + { + return array_merge(parent::getStartingPointsForNavigationMesh(), $this->navmeshPoints); + } + public function getSpawnRotationDefender(): int { return 180; diff --git a/server/src/Map/Map.php b/server/src/Map/Map.php index 15b1223..ed5c352 100644 --- a/server/src/Map/Map.php +++ b/server/src/Map/Map.php @@ -4,6 +4,7 @@ use cs\Core\Box; use cs\Core\Floor; +use cs\Core\PathFinder; use cs\Core\Point; use cs\Core\Wall; @@ -31,6 +32,12 @@ public function setDefendersSpawnPositions(array $positions): void $this->spawnPositionDefender = $positions; } + /** @return Point[] */ + public function getStartingPointsForNavigationMesh(): array + { + return array_merge($this->getSpawnPositionAttacker(), $this->getSpawnPositionDefender()); + } + /** * @return Wall[] */ @@ -83,6 +90,11 @@ public function getBombMaxBlastDistance(): int return 1000; } + public function getNavigationMesh(string $key): ?PathFinder + { + return null; + } + public abstract function getBuyArea(bool $forAttackers): Box; public abstract function getPlantArea(): Box; diff --git a/server/src/Map/TestMap.php b/server/src/Map/TestMap.php index 4575fcb..ff02905 100644 --- a/server/src/Map/TestMap.php +++ b/server/src/Map/TestMap.php @@ -9,6 +9,7 @@ class TestMap extends Map { private Box $buyArea; + public Point $startPointForNavigationMesh; public function __construct() { @@ -22,6 +23,12 @@ public function __construct() ]); $this->buyArea = new Box(new Point(), 99999, 999, 99999); + $this->startPointForNavigationMesh = new Point(100, 0, 100); + } + + public function getStartingPointsForNavigationMesh(): array + { + return [$this->startPointForNavigationMesh]; } public function getBuyArea(bool $forAttackers): Box diff --git a/server/src/Net/ClueSocket.php b/server/src/Net/ClueSocket.php index 1a036a6..9e0ced3 100644 --- a/server/src/Net/ClueSocket.php +++ b/server/src/Net/ClueSocket.php @@ -2,6 +2,7 @@ namespace cs\Net; +use Socket as PHPSocketResource; use Socket\Raw\Exception; use Socket\Raw\Factory; use Socket\Raw\Socket; @@ -13,14 +14,14 @@ class ClueSocket implements NetConnector { private Socket $socket; - private readonly \Socket $resource; + private readonly PHPSocketResource $resource; public function __construct(string $bindAddress) { try { $this->socket = (new Factory())->createServer($bindAddress); + $this->socket->setBlocking(false); $this->resource = $this->socket->getResource(); // @phpstan-ignore-line - socket_set_nonblock($this->resource); // @phpstan-ignore-line } catch (Exception $ex) { throw new NetException($ex->getMessage(), $ex->getCode(), $ex); } diff --git a/server/src/Traits/Player/MovementTrait.php b/server/src/Traits/Player/MovementTrait.php index df6a847..2f156c7 100644 --- a/server/src/Traits/Player/MovementTrait.php +++ b/server/src/Traits/Player/MovementTrait.php @@ -2,7 +2,6 @@ namespace cs\Traits\Player; -use cs\Core\Collision; use cs\Core\GameException; use cs\Core\Point; use cs\Core\Setting; @@ -261,13 +260,13 @@ private function canMoveTo(Point $start, Point $candidate, int $angle): ?bool if ($xMove) { $xGrowing = ($start->x < $candidate->x); $baseX = $candidate->clone()->addX($xGrowing ? $radius : -$radius); - $xWallMaxHeight = $this->findHighestWall($baseX, $height, $radius, $maxWallCeiling, true); + $xWallMaxHeight = $this->world->findHighestWall($baseX, $height, $radius, $maxWallCeiling, true); } $zWallMaxHeight = 0; if ($zMove) { $zGrowing = ($start->z < $candidate->z); $baseZ = $candidate->clone()->addZ($zGrowing ? $radius : -$radius); - $zWallMaxHeight = $this->findHighestWall($baseZ, $height, $radius, $maxWallCeiling, false); + $zWallMaxHeight = $this->world->findHighestWall($baseZ, $height, $radius, $maxWallCeiling, false); } if ($xWallMaxHeight === 0 && $zWallMaxHeight === 0) { // no walls return true; @@ -313,11 +312,11 @@ private function canMoveTo(Point $start, Point $candidate, int $angle): ?bool // Try to move in X axis $oneSideCandidate = $start->clone()->addX($angle > 180 ? -1 : 1); $oneSideCandidateX = $oneSideCandidate->clone()->addX($angle > 180 ? -$radius : $radius); - $wallCeiling = $this->findHighestWall($oneSideCandidateX, $height, $radius, $maxWallCeiling, true); + $wallCeiling = $this->world->findHighestWall($oneSideCandidateX, $height, $radius, $maxWallCeiling, true); if ($wallCeiling > $maxWallCeiling) { // X too tall, try to move in Z axis $oneSideCandidate = $start->clone()->addZ(($angle > 270 || $angle < 90) ? 1 : -1); $oneSideCandidateZ = $oneSideCandidate->clone()->addZ(($angle > 270 || $angle < 90) ? $radius : -$radius); - $wallCeiling = $this->findHighestWall($oneSideCandidateZ, $height, $radius, $maxWallCeiling, false); + $wallCeiling = $this->world->findHighestWall($oneSideCandidateZ, $height, $radius, $maxWallCeiling, false); } if ($wallCeiling > $maxWallCeiling) { // tall walls everywhere return false; @@ -338,39 +337,6 @@ private function canMoveTo(Point $start, Point $candidate, int $angle): ?bool return null; } - private function findHighestWall(Point $bottomCenter, int $height, int $radius, int $maxWallCeiling, bool $xWall): int - { - $base = $xWall ? $bottomCenter->x : $bottomCenter->z; - if ($base < 0) { - return $maxWallCeiling + 1; - } - $walls = $xWall ? $this->world->getXWalls($base) : $this->world->getZWalls($base); - if ($walls === []) { - return 0; - } - - $width = 2 * $radius; - $highestWallCeiling = 0; - $candidatePlane = $bottomCenter->to2D($xWall ? 'zy' : 'xy')->addX(-$radius); - foreach ($walls as $wall) { - $wallCeiling = $wall->getCeiling(); - if ($wallCeiling <= $bottomCenter->y) { - continue; - } - if (!Collision::planeWithPlane($wall->getPoint2DStart(), $wall->width, $wall->height, $candidatePlane, $width, $height)) { - continue; - } - if ($wallCeiling > $maxWallCeiling) { - return $wallCeiling; - } - if ($wallCeiling > $highestWallCeiling) { - $highestWallCeiling = $wallCeiling; - } - } - - return $highestWallCeiling; - } - private function collisionWithPlayer(Point $candidate, int $radius): bool { return (null !== $this->world->isCollisionWithOtherPlayers($this->id, $candidate, $radius, $this->headHeight)); diff --git a/test/og/Shooting/HighExplosiveGrenadeTest.php b/test/og/Shooting/HighExplosiveGrenadeTest.php new file mode 100644 index 0000000..548018a --- /dev/null +++ b/test/og/Shooting/HighExplosiveGrenadeTest.php @@ -0,0 +1,184 @@ +createNoPauseGame(); + $game->getPlayer(1)->setPosition(new Point(500,0, 500)); + $health = $game->getPlayer(1)->getHealth(); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getInventory()->earnMoney(1000), + fn(Player $p) => $p->buyItem(BuyMenuItem::KEVLAR_BODY), + fn(Player $p) => $p->getSight()->look(0, -90), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_HE)), + fn(Player $p) => $this->assertInstanceOf(HighExplosive::class, $p->getEquippedItem()), + $this->waitNTicks(HighExplosive::equipReadyTimeMs), + fn(Player $p) => $this->assertSame($health, $p->getHealth()), + fn(Player $p) => $this->assertNotNull($p->attackSecondary()), + $this->waitXTicks(100), + fn(Player $p) => $this->assertLessThan($health, $p->getHealth()), + $this->endGame(), + ]); + } + + public function testDamageEnemy(): void + { + $game = $this->createNoPauseGame(); + $enemy = new Player(2, Color::ORANGE, false); + $game->addPlayer($enemy); + $enemy->setPosition(new Point(430, 0, 500)); + $enemyHealth = $enemy->getHealth(); + + $game->getWorld()->addWall(new Wall(new Point(0, 0, 600), true, 800)); + $game->getWorld()->addWall(new Wall(new Point(560, 0, 0), false, 800)); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getSight()->look(40, -10), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_HE)), + fn(Player $p) => $this->assertSame(500, $p->getMoney()), + fn(Player $p) => $this->assertInstanceOf(HighExplosive::class, $p->getEquippedItem()), + $this->waitNTicks(HighExplosive::equipReadyTimeMs), + fn(Player $p) => $this->assertSame($enemyHealth, $enemy->getHealth()), + fn(Player $p) => $this->assertNotNull($p->attack()), + $this->waitXTicks(100), + fn(Player $p) => $this->assertLessThan($enemyHealth, $enemy->getHealth()), + fn(Player $p) => $this->assertSame(500, $p->getMoney()), + $this->endGame(), + ]); + } + + public function testKillEnemy(): void + { + $game = $this->createNoPauseGame(); + $enemy = new Player(2, Color::ORANGE, false); + $game->addPlayer($enemy); + $enemy->setPosition(new Point(430, 0, 500)); + + $game->getWorld()->addWall(new Wall(new Point(0, 0, 600), true, 800)); + $game->getWorld()->addWall(new Wall(new Point(560, 0, 0), false, 800)); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getSight()->look(40, -2), + fn(Player $p) => $p->equipSecondaryWeapon(), + $this->waitNTicks(PistolGlock::equipReadyTimeMs), + fn(Player $p) => $this->assertPlayerHit($p->attack()), + fn(Player $p) => $p->getSight()->look(40, -10), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_HE)), + fn(Player $p) => $this->assertSame(500, $p->getMoney()), + fn(Player $p) => $this->assertInstanceOf(HighExplosive::class, $p->getEquippedItem()), + $this->waitNTicks(HighExplosive::equipReadyTimeMs), + fn(Player $p) => $this->assertSame(71, $enemy->getHealth()), + fn(Player $p) => $this->assertNotNull($p->attack()), + $this->waitXTicks(100), + ]); + + $this->assertSame(2, $game->getRoundNumber()); + $this->assertSame(500 + 300, $game->getPlayer(1)->getMoney()); + } + + public function testWallBlockDamage(): void + { + $game = $this->createNoPauseGame(); + $game->getWorld()->addWall(new Wall(new Point(0, 0, 400), true, 800, 1000)); + $game->getPlayer(1)->setPosition(new Point(100, 0, 500)); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getSight()->look(0, -90), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_HE)), + $this->waitNTicks(HighExplosive::equipReadyTimeMs), + fn(Player $p) => $this->assertNotNull($p->attack()), + fn(Player $p) => $p->setPosition(new Point(100, 0, 300)), + $this->waitNTicks(800), + fn(Player $p) => $this->assertSame(100, $p->getHealth()), + $this->endGame(), + ]); + } + + public function testSmallWallBlockDamagePartially(): void + { + $noWallHealth = null; + + $game = $this->createNoPauseGame(); + $game->getPlayer(1)->setPosition(new Point(100, 0, 500)); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getSight()->look(0, -90), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_HE)), + $this->waitNTicks(HighExplosive::equipReadyTimeMs), + fn(Player $p) => $this->assertNotNull($p->attackSecondary()), + fn(Player $p) => $p->setPosition(new Point(100, 0, 300)), + $this->waitNTicks(2000), + function (Player $p) use (&$noWallHealth) { + $this->assertLessThan(100, $p->getHealth()); + $noWallHealth = $p->getHealth(); + }, + $this->endGame(), + ]); + + //// + + $game = $this->createNoPauseGame(); + $game->getWorld()->addWall(new Wall(new Point(0, 0, 400), true, 800, Setting::playerHeadHeightCrouch() / 2)); + $game->getPlayer(1)->setPosition(new Point(100, 0, 500)); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getSight()->look(0, -90), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_HE)), + $this->waitNTicks(HighExplosive::equipReadyTimeMs), + fn(Player $p) => $this->assertNotNull($p->attackSecondary()), + fn(Player $p) => $p->setPosition(new Point(100, 0, 300)), + $this->waitNTicks(2000), + function (Player $p) use (&$noWallHealth) { + $this->assertGreaterThan($noWallHealth, $p->getHealth()); + $this->assertLessThan(100, $p->getHealth()); + }, + $this->endGame(), + ]); + } + + public function testDistanceLowerDamage(): void + { + $game = $this->createNoPauseGame(); + $game->addPlayer(new Player(2, Color::ORANGE, false)); + $game->getPlayer(2)->setPosition(new Point(600, 0, 600)); + $game->addPlayer(new Player(3, Color::ORANGE, false)); + $game->getPlayer(3)->setPosition(new Point(700, 0, 700)); + $game->getPlayer(1)->setPosition(new Point(500, 0, 500)); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getSight()->look(45, -89), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_HE)), + fn(Player $p) => $this->assertInstanceOf(HighExplosive::class, $p->getEquippedItem()), + $this->waitNTicks(HighExplosive::equipReadyTimeMs), + fn(Player $p) => $this->assertNotNull($p->attackSecondary()), + $this->waitXTicks(100), + $this->endGame(), + ]); + + $health = $game->getPlayer(1)->getHealth(); + $this->assertSame(1, $game->getRoundNumber()); + $this->assertCount(3, $game->getAlivePlayers()); + $this->assertLessThan(100, $health); + $this->assertLessThan(100, $game->getPlayer(2)->getHealth()); + $this->assertLessThan(100, $game->getPlayer(3)->getHealth()); + $this->assertGreaterThan($health, $game->getPlayer(2)->getHealth()); + $health = $game->getPlayer(2)->getHealth(); + $this->assertGreaterThan($health, $game->getPlayer(3)->getHealth()); + } + +} diff --git a/test/og/Shooting/MolotovGrenadeTest.php b/test/og/Shooting/MolotovGrenadeTest.php new file mode 100644 index 0000000..1af2ead --- /dev/null +++ b/test/og/Shooting/MolotovGrenadeTest.php @@ -0,0 +1,199 @@ +createNoPauseGame(2); + $game->getPlayer(1)->setPosition(new Point(500, 0, 500)); + $game->getWorld()->addBox(new Box(new Point(), 1000, 3000, 1000)); + $health = $game->getPlayer(1)->getHealth(); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getInventory()->earnMoney(1000), + fn(Player $p) => $p->buyItem(BuyMenuItem::KEVLAR_BODY), + fn(Player $p) => $p->getSight()->look(0, -90), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_MOLOTOV)), + fn(Player $p) => $this->assertInstanceOf(Molotov::class, $p->getEquippedItem()), + $this->waitNTicks(Molotov::equipReadyTimeMs), + fn(Player $p) => $this->assertSame($health, $p->getHealth()), + fn(Player $p) => $this->assertNotNull($p->attackSecondary()), + fn(Player $p) => $this->assertSame(1, $game->getRoundNumber()), + $this->waitNTicks(Molotov::MAX_TIME_MS), + fn(Player $p) => $this->assertSame(2, $game->getRoundNumber(), 'Player should not survive molly'), + fn(Player $p) => $this->assertSame(1, $game->getScore()->getPlayerStat($p->getId())->getDeaths()), + $this->endGame(), + ]); + } + + public function testWallBlockFire(): void + { + $game = $this->createNoPauseGame(); + $box = new Box(new Point(0, 0, 300), 1000, 300, 10); + $game->getWorld()->addBox($box); + $game->getWorld()->addBox(new Box(new Point(), 1000, 3000, 1000)); + $health = $game->getPlayer(1)->getHealth(); + $game->getPlayer(1)->setPosition(new Point(100, 0, 50)); + $enemy = new Player(2, Color::BLUE, false); + $game->addPlayer($enemy); + $enemyHealth = $enemy->getHealth(); + $enemy->setPosition($box->getBase()->setX(100)->addZ($enemy->getBoundingRadius() + 20)); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getSight()->look(0, -30), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_MOLOTOV)), + $this->waitNTicks(Molotov::equipReadyTimeMs), + fn(Player $p) => $this->assertNotNull($p->attack()), + $this->waitNTicks(2000), + fn(Player $p) => $p->setPosition(new Point(999, 0, 999)), + $this->waitNTicks(Molotov::MAX_TIME_MS), + fn(Player $p) => $this->assertLessThan($health, $p->getHealth()), + $this->endGame(), + ]); + + $this->assertSame($enemyHealth, $enemy->getHealth()); + } + + public function testMolotovSpreadMaze(): void + { + $game = $this->createNoPauseGame(10); + $game->getWorld()->addBox(new Box(new Point(0, 0, 500), 1000, 300, 10)); + $box = new Box(new Point(0, 0, 300), 250, 300, 10); + $game->getWorld()->addBox($box); + $game->getWorld()->addBox(new Box(new Point(0, 0, 100), 1000, 300, 10)); + $game->getWorld()->addBox(new Box(new Point(350, 0, 0), 100, 300, 700)); + $game->getWorld()->addBox(new Box(new Point(150, 0, 0), 10, Setting::playerObstacleOvercomeHeight() + 10, 300)); + $game->getPlayer(1)->setPosition(new Point(50, 0, 190)); + $enemy = new Player(2, Color::BLUE, false); + $game->addPlayer($enemy); + $enemy->setPosition($box->getBase()->setX(100)->addZ($enemy->getBoundingRadius() + 20)); + $game->getTestMap()->startPointForNavigationMesh->setFrom($enemy->getPositionClone()); + + $this->playPlayer($game, [ + fn(Player $p) => $p->getSight()->look(100, -20), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_MOLOTOV)), + $this->waitNTicks(Molotov::equipReadyTimeMs), + fn(Player $p) => $this->assertNotNull($p->attack()), + $this->waitNTicks(Molotov::MAX_TIME_MS), + $this->endGame(), + ]); + + $this->assertSame(2, $game->getRoundNumber()); + $this->assertTrue($game->getScore()->attackersIsWinning()); + $this->assertSame(1, $game->getScore()->getScoreAttackers()); + $this->assertSame(0, $game->getScore()->getScoreDefenders()); + + $this->assertSame(1, $game->getScore()->getPlayerStat(1)->getKills()); + $this->assertSame(100, $game->getScore()->getPlayerStat(1)->getDamage()); + $this->assertSame(0, $game->getScore()->getPlayerStat($enemy->getId())->getKills()); + $this->assertSame(0, $game->getScore()->getPlayerStat($enemy->getId())->getDamage()); + + } + + public function testStandOnTallWallAvoidFire(): void + { + $game = $this->createNoPauseGame(10); + $game->getWorld()->addBox(new Box(new Point(500, 0, 500), 1, $game->getWorld()::GRENADE_NAVIGATION_MESH_OBJECT_HEIGHT + 1, 1)); + $game->getWorld()->addBox(new Box(new Point(), 1000, 3000, 1000)); + $health = $game->getPlayer(1)->getHealth(); + + $this->playPlayer($game, [ + fn(Player $p) => $p->setPosition(new Point(500, 0, 460)), + fn(Player $p) => $p->getSight()->look(0, -90), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_MOLOTOV)), + fn(Player $p) => $this->assertInstanceOf(Molotov::class, $p->getEquippedItem()), + $this->waitNTicks(Molotov::equipReadyTimeMs), + fn(Player $p) => $this->assertSame($health, $p->getHealth()), + fn(Player $p) => $this->assertNotNull($p->attack()), + $this->waitNTicks(300), + function (Player $p) use (&$health, $game) { + $this->assertLessThan($health, $p->getHealth()); + $p->setPosition(new Point(500, $game->getWorld()::GRENADE_NAVIGATION_MESH_OBJECT_HEIGHT + 10, 500)); + $health = $p->getHealth(); + }, + $this->waitNTicks(Molotov::MAX_TIME_MS), + function (Player $p) use (&$health) { + $this->assertSame($health, $p->getHealth()); + }, + $this->endGame(), + ]); + } + + public function testTunnelExpand(): void + { + $game = $this->createNoPauseGame(); + $game->getWorld()->addBox(new Box(new Point(), 1000, 3000, 1000)); + $grenadeTileSize = $game->getWorld()::GRENADE_NAVIGATION_MESH_TILE_SIZE; + $game->getWorld()->addBox(new Box(new Point(0, 0, 500), $grenadeTileSize, 500, 2)); + $game->getWorld()->addBox(new Box(new Point(0, 0, 500 - ($grenadeTileSize * 4)), $grenadeTileSize, 500, 2)); + $game->getWorld()->addBox(new Box(new Point($grenadeTileSize, 0, 500 - ($grenadeTileSize * 1)), 1000 - (3 * $grenadeTileSize), 500, 2)); + $game->getWorld()->addBox(new Box(new Point($grenadeTileSize, 0, 500 - ($grenadeTileSize * 3)), 1000 - (3 * $grenadeTileSize), 500, 2)); + $game->getWorld()->addBox(new Box(new Point($grenadeTileSize, 0, 500 - $grenadeTileSize), 2, 500, $grenadeTileSize)); + $game->getWorld()->addBox(new Box(new Point($grenadeTileSize, 0, 500 - ($grenadeTileSize * 4)), 2, 500, $grenadeTileSize)); + $game->addPlayer(new Player(2, Color::ORANGE, false)); + $game->addPlayer(new Player(3, Color::BLUE, false)); + $game->addPlayer(new Player(4, Color::BLUE, false)); + $br = $game->getPlayer(1)->getBoundingRadius() + 1; + $game->getPlayer(2)->setPosition(new Point(700, 0, 500 - ($grenadeTileSize * 2))); + $game->getPlayer(3)->setPosition((new Point(100, 0, 500 - $grenadeTileSize))->addZ($br)); + $game->getPlayer(4)->setPosition((new Point(100, 0, 500 - ($grenadeTileSize * 3)))->addZ(-$br)); + + $this->playPlayer($game, [ + fn(Player $p) => $p->setPosition(new Point((int)ceil($grenadeTileSize / 2) + 1, 0, 500 - ($grenadeTileSize * 2))), + fn(Player $p) => $p->crouch(), + fn(Player $p) => $p->getSight()->look(0, -90), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_MOLOTOV)), + $this->waitNTicks(Molotov::equipReadyTimeMs), + fn(Player $p) => $this->assertNotNull($p->attack()), + fn(Player $p) => $p->setPosition($p->getPositionClone()->addX(1000 + $p->getBoundingRadius())), + fn(Player $p) => $this->assertSame(100, $p->getHealth()), + $this->waitNTicks(Molotov::MAX_TIME_MS), + $this->endGame(), + ]); + + $this->assertLessThan(100, $game->getPlayer(2)->getHealth()); + $this->assertSame(100 - $game->getPlayer(2)->getHealth(), $game->getScore()->getPlayerStat(1)->getDamage()); + $this->assertSame(1, $game->getScore()->getPlayerStat(2)->getDeaths()); + $this->assertSame(0, $game->getScore()->getPlayerStat(3)->getDeaths()); + $this->assertSame(0, $game->getScore()->getPlayerStat(4)->getDeaths()); + $this->assertSame(0, $game->getScore()->getPlayerStat(1)->getDeaths()); + } + + public function testFlameClimbStairs(): void + { + $game = $this->createNoPauseGame(); + $game->getWorld()->addBox(new Box(new Point(), 1000, 3000, 1000)); + $grenadeTileSize = $game->getWorld()::GRENADE_NAVIGATION_MESH_TILE_SIZE; + $game->getWorld()->addBox(new Box(new Point($grenadeTileSize * 3, 0, 300), 1000, 3000, 1000)); + $game->getWorld()->addRamp(new Ramp(new Point(-1, 0, 300), new Point2D(0, 1), 100, $grenadeTileSize * 10)); + + $this->playPlayer($game, [ + fn(Player $p) => $p->setPosition(new Point($grenadeTileSize * 2, 0, 200)), + fn(Player $p) => $p->getSight()->look(0, -90), + fn(Player $p) => $this->assertTrue($p->buyItem(BuyMenuItem::GRENADE_MOLOTOV)), + $this->waitNTicks(Molotov::equipReadyTimeMs), + fn(Player $p) => $this->assertNotNull($p->attack()), + fn(Player $p) => $p->setPosition($p->getPositionClone()->addPart(0, 400, 300)), + fn(Player $p) => $this->assertSame(100, $p->getHealth()), + fn(Player $p) => $this->assertSame(100, $p->getHealth()), + $this->waitNTicks(Molotov::MAX_TIME_MS), + ]); + + $this->assertSame(2, $game->getRoundNumber()); + } + +} diff --git a/test/og/TestGame.php b/test/og/TestGame.php index b89d086..f5b7cf1 100644 --- a/test/og/TestGame.php +++ b/test/og/TestGame.php @@ -6,6 +6,7 @@ use cs\Core\Game; use cs\Core\GameException; use cs\Core\Setting; +use cs\Map\TestMap; use cs\Net\Protocol\TextProtocol; /** @@ -99,4 +100,14 @@ public function startDebug(string $path = '/tmp/cs.demo.json'): void $this->gameStates = []; } + public function getTestMap(): TestMap + { + $map = $this->getWorld()->getMap(); + if ($map instanceof TestMap) { + return $map; + } + + throw new GameException("No test map is loaded"); + } + } diff --git a/test/og/Unit/PerformanceTest.php b/test/og/Unit/PerformanceTest.php index 829b603..732695e 100644 --- a/test/og/Unit/PerformanceTest.php +++ b/test/og/Unit/PerformanceTest.php @@ -11,7 +11,9 @@ use cs\Core\Wall; use cs\Enum\BuyMenuItem; use cs\Enum\Color; +use cs\Equipment\Molotov; use cs\Event\AttackResult; +use cs\Interface\Flammable; use cs\Map\BoxMap; use cs\Map\Map; use cs\Map\TestMap; @@ -22,15 +24,14 @@ class PerformanceTest extends BaseTest { + private static float $timeScale; public static function setUpBeforeClass(): void { gc_collect_cycles(); parent::setUpBeforeClass(); - if (getenv('CI') !== false) { - self::markTestSkipped('CI too slow'); - } + self::$timeScale = 1; $sum = 0; $timer = new Timer(); $timer->start(); @@ -39,7 +40,10 @@ public static function setUpBeforeClass(): void } $took = $timer->stop(); if ($took->asMicroseconds() > 7500) { - self::markTestSkipped('Performance test skipped'); + self::$timeScale = 1.08; + } + if (getenv('CI') !== false) { + self::$timeScale = 1.10; } Util::$TICK_RATE = 20; @@ -120,7 +124,7 @@ public function testPlayersRangeShooting(): void $this->assertLessThanOrEqual($range + 50, $result->getBullet()->getDistanceTraveled()); } $this->assertGreaterThanOrEqual($range, PistolGlock::range); - $this->assertLessThan(21, $took->asMilliseconds()); + $this->assertLessThan(21 * self::$timeScale, $took->asMilliseconds()); } public function testTwoPlayersRangeShootingEachOther(): void @@ -157,7 +161,7 @@ public function testTwoPlayersRangeShootingEachOther(): void $this->assertLessThan(100, $player->getHealth(), "Player: '{$player->getId()}'"); $this->assertTrue($player->isAlive()); } - $this->assertLessThan(27, $took->asMilliseconds()); + $this->assertLessThan(27 * self::$timeScale, $took->asMilliseconds()); } public function testPlayersMoving(): void @@ -195,7 +199,7 @@ public function testPlayersMoving(): void $this->assertGreaterThan(50, $player->getPositionClone()->z); $this->assertGreaterThan(200, $player->getPositionClone()->z); } - $this->assertLessThan(17, $took->asMilliseconds()); + $this->assertLessThan(17 * self::$timeScale, $took->asMilliseconds()); } public function test3DMovement(): void @@ -210,7 +214,7 @@ public function test3DMovement(): void } $took = $timer->stop(); $this->assertSame([49726, 66913, 55226], $coordinates); - $this->assertLessThan(38, $took->asMilliseconds()); + $this->assertLessThan(38 * self::$timeScale, $took->asMilliseconds()); } public function test2DMovement(): void @@ -225,7 +229,87 @@ public function test2DMovement(): void } $took = $timer->stop(); $this->assertSame([66913, 74314], $coordinates); - $this->assertLessThan(23, $took->asMilliseconds()); + $this->assertLessThan(23 * self::$timeScale, $took->asMilliseconds()); + } + + public function testMolotov(): void + { + $game = GameFactory::createDebug(); + $game->loadMap($this->createMolotovMap()); + + $timer = new Timer(); + $timer->start(); + $game->getWorld()->regenerateNavigationMeshes(); + $took = $timer->stop(); + $this->assertLessThan(120 * self::$timeScale, $took->asMilliseconds()); + + $player = new Player(1, Color::GREEN, true); + $game->addPlayer($player); + $this->assertTrue($player->buyItem(BuyMenuItem::GRENADE_MOLOTOV)); + $tickId = $game->getTickId(); + foreach (range(0, Util::millisecondsToFrames(Molotov::equipReadyTimeMs)) as $i) { + $game->tick(++$tickId); + } + $flammableItem = $player->getEquippedItem(); + $this->assertInstanceOf(Flammable::class, $flammableItem); + + $timer->start(); + $this->assertNotNull($player->attack()); + $took = $timer->stop(); + $this->assertLessThan(0.6 * self::$timeScale, $took->asMilliseconds()); + + $epicentre = $player->getPositionClone()->addY($flammableItem->getBoundingRadius()); + $timer->start(); + $game->getWorld()->processFlammableExplosion($player, $epicentre, $flammableItem); + $took = $timer->stop(); + $this->assertLessThan(0.6 * self::$timeScale, $took->asMilliseconds()); + + $samplesCount = 1; + $timer->start(); + foreach (range(1, $samplesCount) as $i) { + $game->tick(++$tickId); + } + $took = $timer->stop(); + $this->assertLessThan(100, $player->getHealth()); + $this->assertLessThan(0.3 * self::$timeScale, $took->asMilliseconds() / $samplesCount); + + $health = $player->getHealth(); + $samplesCount = 10; + $timer->start(); + foreach (range(1, $samplesCount) as $i) { + $game->tick(++$tickId); + } + $took = $timer->stop(); + $this->assertLessThan($health, $player->getHealth()); + $this->assertLessThan(0.4 * self::$timeScale, $took->asMilliseconds() / $samplesCount); + } + + private function createMolotovMap(): Map + { + return new class() extends BoxMap { + private Box $boundary; + + public function __construct() + { + $this->boundary = new Box(new Point(11, 12, 13), 1000, 2000, 1000); + $this->addBox($this->boundary); + } + + public function getBuyArea(bool $forAttackers): Box + { + return $this->boundary; + } + + public function getPlantArea(): Box + { + return $this->boundary; + } + + public function getSpawnPositionAttacker(): array + { + return [new Point(500, 12, 500)]; + } + }; } private function createMap(int $depth = 2000): Map diff --git a/test/og/World/NavigationMeshTest.php b/test/og/World/NavigationMeshTest.php new file mode 100644 index 0000000..c2117d3 --- /dev/null +++ b/test/og/World/NavigationMeshTest.php @@ -0,0 +1,119 @@ + [ + ['2,0,2', new Point(1, 0, 1)], + ['2,0,2', new Point(2, 0, 2)], + ['2,0,2', new Point(3, 0, 3)], + ['5,0,2', new Point(4, 0, 1)], + ['5,0,2', new Point(5, 0, 1)], + ['5,0,2', new Point(6, 0, 1)], + ['8,0,5', new Point(9, 0, 4)], + ], + 31 => [ + ['16,0,16', new Point(3, 0, 1)], + ['47,0,16', new Point(32, 0, 2)], + ['47,0,16', new Point(42, 0, 17)], + ['47,0,47', new Point(42, 0, 59)], + ['47,0,47', new Point(59, 0, 59)], + ['47,0,47', new Point(59, 0, 59)], + ['326,333,326', new Point(333, 333, 333)], + ['450,0,295', new Point(450, 0, 285)], + ['450,0,295', new Point(461, 0, 285)], + ], + ]; + $world = new World($this->createTestGame()); + foreach ($data as $tileSize => $tests) { + $finder = new PathFinder($world, $tileSize, 10); + foreach ($tests as [$expected, $point]) { + $msg = "Size {$tileSize} point {$point->hash()}"; + $finder->convertToNavMeshNode($point); + $this->assertSame($expected, $point->hash(), $msg); + } + } + } + + public function testSimple(): void + { + $game = $this->createTestGame(); + $game->getWorld()->addBox(new Box(new Point(), 10, 1000, 10)); + $game->getTestMap()->startPointForNavigationMesh->set(5, 0, 5); + + $path = $game->getWorld()->buildNavigationMesh(3, 100); + $this->assertSame(9, $path->getGraph()->getNodesCount()); + $this->assertSame(24, $path->getGraph()->getEdgeCount()); + + $start = new Point(4, 0, 1); + $path->convertToNavMeshNode($start); + $startNode = $path->getGraph()->getNodeById($start->hash()); + $this->assertNotNull($startNode); + $this->assertCount(3, $path->getGraph()->getNeighbors($startNode)); + } + + public function testBoundary(): void + { + $game = $this->createTestGame(); + $game->getWorld()->addBox(new Box(new Point(), 10, 1000, 10)); + $boxPoint = new Point(5, 0, 1); + $game->getWorld()->addBox(new Box($boxPoint, 10, 1000, 10)); + $game->getTestMap()->startPointForNavigationMesh->set(1, 0, 9); + $path = $game->getWorld()->buildNavigationMesh(3, 100); + + $candidate = $boxPoint->clone()->addX(-1)->setZ(5); + $this->assertNull($path->getGraph()->getNodeById($candidate->hash())); + + $closestCandidate = $candidate->clone(); + $path->convertToNavMeshNode($closestCandidate); + $this->assertNull($path->getGraph()->getNodeById($closestCandidate->hash())); + + $validPoint = $path->tryFindClosestTile($candidate); + $this->assertNotNull($validPoint); + $this->assertLessThan($closestCandidate->x, $validPoint->x); + $this->assertNotNull($path->getGraph()->getNodeById($validPoint->hash())); + $this->assertSame('2,0,5', $validPoint->hash()); + } + + public function testOneWayDirection(): void + { + $game = $this->createTestGame(); + $height = $game->getWorld()::GRENADE_NAVIGATION_MESH_OBJECT_HEIGHT; + $doubleHeight = $height * 2; + $game->getWorld()->addBox(new Box(new Point(), 10, 1000, 10)); + + $game->getWorld()->addBox(new Box(new Point(7, 0, 0), 10, $doubleHeight, 10)); + $game->getWorld()->addBox(new Box(new Point(7, 0, 0), 10, 1000, 10)); + $game->getTestMap()->startPointForNavigationMesh->set(8, $doubleHeight, 8); + + $path = $game->getWorld()->buildNavigationMesh(3, $height); + $this->assertSame(9, $path->getGraph()->getNodesCount()); + $this->assertSame(21, $path->getGraph()->getEdgeCount()); + + $start = new Point(4, 0, 1); + $path->convertToNavMeshNode($start); + $startNode = $path->getGraph()->getNodeById($start->hash()); + $this->assertNotNull($startNode); + $this->assertCount(2, $path->getGraph()->getNeighbors($startNode)); + + $stepNode = $path->getGraph()->getNodeById((new Point(8, $doubleHeight, 8))->hash()); + $this->assertNotNull($stepNode); + $this->assertSame(['path' => [], 'cost' => INF], $path->getGraph()->shortestPathDijkstra($startNode, $stepNode)); + $this->assertSame(['path' => ["8,{$doubleHeight},8", '5,0,8', '5,0,5', '5,0,2'], 'cost' => 3.0], $path->getGraph()->shortestPathDijkstra($stepNode, $startNode)); + + $skyNode = $path->getGraph()->getNodeById((new Point(8, 1000, 4))->hash()); + $this->assertNull($skyNode); + } + +} diff --git a/www/assets/js/Enums.js b/www/assets/js/Enums.js index ad1602f..6cc6f75 100644 --- a/www/assets/js/Enums.js +++ b/www/assets/js/Enums.js @@ -32,6 +32,7 @@ const EventList = { PlantEvent: 10, ThrowEvent: 11, DropEvent: 12, + GrillEvent: 13, } // server/src/Enum/GameOverReason.php @@ -85,6 +86,7 @@ const SoundType = { PLAYER_STEP: 1, ITEM_ATTACK: 2, ITEM_ATTACK2: 3, + FLAME_SPAWN: 4, ITEM_BUY: 5, BULLET_HIT: 6, PLAYER_DEAD: 7, @@ -102,6 +104,7 @@ const SoundType = { GRENADE_AIR: 19, ITEM_DROP_AIR: 20, ITEM_DROP_LAND: 21, + FLAME_EXTINGUISH: 22, } // server/src/Enum/ArmorType.php diff --git a/www/assets/js/EventProcessor.js b/www/assets/js/EventProcessor.js index 232408a..ed2631d 100644 --- a/www/assets/js/EventProcessor.js +++ b/www/assets/js/EventProcessor.js @@ -108,6 +108,10 @@ export class EventProcessor { game.itemDrop(data.item, data.id) } + eventsCallback[EventList.GrillEvent] = function (data) { + // todo play grill song + } + this.#callbacks = eventsCallback } } diff --git a/www/assets/js/Game.js b/www/assets/js/Game.js index 0a19390..6c04e46 100644 --- a/www/assets/js/Game.js +++ b/www/assets/js/Game.js @@ -25,6 +25,7 @@ export class Game { #eventProcessor #dropItems = {}; #throwables = {}; + #flammable = {}; #roundIntervalIds = []; #roundDamage = {did: {},got: {}} #playerSlotsVisibleModels = [ @@ -55,6 +56,7 @@ export class Game { this.#roundIntervalIds = [] Object.keys(this.#dropItems).forEach((id) => this.itemPickUp(id)) Object.keys(this.#throwables).forEach((id) => this.removeGrenade(id)) + Object.keys(this.#flammable).forEach((fireId) => Object.keys(this.#flammable[fireId]).forEach((flameId) => this.destroyFlame(fireId, flameId))) this.#world.reset() } @@ -194,6 +196,12 @@ export class Game { if (data.type === SoundType.ITEM_PICKUP) { this.itemPickUp(data.extra.id) } + if (data.type === SoundType.FLAME_SPAWN) { + this.spawnFlame(data.position, data.extra.size, data.extra.height, data.extra.fire, `${data.position.x}-${data.position.y}-${data.position.z}`) + } + if (data.type === SoundType.FLAME_EXTINGUISH) { + this.destroyFlame(data.extra.fire, `${data.position.x}-${data.position.y}-${data.position.z}`) + } if (data.type === SoundType.ITEM_DROP_AIR) { const item = this.#dropItems[data.extra.id]; item.rotation.x -= 0.1 @@ -275,9 +283,12 @@ export class Game { game.#roundIntervalIds.push(setTimeout(() => this.removeGrenade(throwableId), 18000)) return; } + if (item.slot === InventorySlot.SLOT_GRENADE_MOLOTOV || item.slot === InventorySlot.SLOT_GRENADE_HE) { + return; + } console.warn("No handler for grenade: ", item) - game.#roundIntervalIds.push(setTimeout(() => this.removeGrenade(throwableId), 1000)) // todo responsive volumetric smokes, flashes, fire etc. + game.#roundIntervalIds.push(setTimeout(() => this.removeGrenade(throwableId), 1000)) // todo responsive volumetric smokes, etc. } itemDrop(item, id) { @@ -301,6 +312,23 @@ export class Game { delete this.#throwables[id] } + spawnFlame(point, size, height, fireId, flameId) { + if (this.#flammable[fireId] === undefined) { + this.#flammable[fireId] = {} + } + height = lerp(height, randomInt(10, 16), Math.min(Math.sqrt(Object.keys(this.#flammable[fireId]).length) / (randomInt(90, 110) / 10), 1)) + const flame = this.#world.spawnFlame(size, height) + this.#flammable[fireId][flameId] = flame + flame.position.set(point.x, point.y + (height / 2), -1 * point.z) + flame.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), Math.random() * 6.28) + } + + destroyFlame(fireId, flameId) { + const flame = this.#flammable[fireId][flameId] + this.#world.destroyObject(flame) + delete this.#flammable[fireId][flameId] + } + bombPlanted(timeMs, position) { const world = this.#world world.spawnBomb(position) diff --git a/www/assets/js/SoundRepository.js b/www/assets/js/SoundRepository.js index aa72f44..dffeb60 100644 --- a/www/assets/js/SoundRepository.js +++ b/www/assets/js/SoundRepository.js @@ -133,6 +133,12 @@ export class SoundRepository { return null } + if (type === SoundType.FLAME_SPAWN) { + return null // todo + } + if (type === SoundType.FLAME_EXTINGUISH) { + return null // todo + } if (type === SoundType.GRENADE_AIR) { return '575509__awildfilli__granada_tiro.wav' } diff --git a/www/assets/js/World.js b/www/assets/js/World.js index 66c51cc..092aff4 100644 --- a/www/assets/js/World.js +++ b/www/assets/js/World.js @@ -9,6 +9,7 @@ export class World { #audioLoader #modelRepository #decals = [] + #flames = [] volume = 30 constructor() { @@ -172,6 +173,20 @@ export class World { return dropItem } + spawnFlame(size, height) { + let mesh = new THREE.Mesh( + new THREE.ConeGeometry(size, height, randomInt(4, 7)), + new THREE.MeshStandardMaterial({color: new THREE.Color(`hsl(53, 100%, ${Math.random() * 70 + 20}%, 1)`)}), + ) + + mesh.castShadow = false + mesh.receiveShadow = false + + this.#scene.add(mesh) + this.#flames.push(mesh) + return mesh + } + bulletWallHit(position, surface, radius) { const hit = new THREE.Mesh( new THREE.CylinderGeometry(radius, radius, .4, 8, 1), @@ -206,6 +221,8 @@ export class World { reset() { this.clearDecals() + this.#flames.forEach((item) => this.destroyObject(item)) + this.#flames = [] const bomb = this.#modelRepository.getBomb() if (bomb.parent && bomb.parent.name === 'MainScene') { bomb.visible = false diff --git a/www/assets/js/hud/KillFeed.js b/www/assets/js/hud/KillFeed.js index e6f4171..08c988d 100644 --- a/www/assets/js/hud/KillFeed.js +++ b/www/assets/js/hud/KillFeed.js @@ -12,13 +12,11 @@ export class KillFeed { showKill(playerCulprit, playerDead, wasHeadshot, playerMe, killedItemId) { let killedByBomb = false this.#scoreBoard.updatePlayerIsDead(playerDead) - if (playerCulprit.id === playerDead.id) { // suicide or bomb - if (killedItemId === ItemId.SolidSurface) { // suicide + if (playerCulprit.id === playerDead.id) { // suicide + if (killedItemId === ItemId.SolidSurface) { this.#scoreBoard.updatePlayerKills(playerDead, -1) - } else if (killedItemId === ItemId.Bomb) { // bomb + } else if (killedItemId === ItemId.Bomb) { killedByBomb = true - } else { - throw new Error("New killing item?") } } else if (playerCulprit.isAttacker === playerDead.isAttacker) { // team kill this.#scoreBoard.updatePlayerKills(playerCulprit, -1) diff --git a/www/assets/js/utils.js b/www/assets/js/utils.js index e4900fe..de9257b 100644 --- a/www/assets/js/utils.js +++ b/www/assets/js/utils.js @@ -37,6 +37,14 @@ function scopeLevelToZoom(scopeLevel) { return scopeLevel * 2.2 } +function randomInt(start, end) { + return THREE.MathUtils.randInt(start, end) +} + +function lerp(start, end, percentage) { + return THREE.MathUtils.lerp(start, end, percentage) +} + function msToTick(timeMs) { return Math.ceil(timeMs / window._csfGlobal.tickMs) } diff --git a/www/demoPlayer.php b/www/demoPlayer.php index 1b05755..55c3ef7 100644 --- a/www/demoPlayer.php +++ b/www/demoPlayer.php @@ -217,6 +217,15 @@ }, showTrajectory: false, throwables: [], + flammable: [], + createBox: function (width, height, depth, color) { + let box = new THREE.Mesh( + new THREE.BoxGeometry(width, height, depth), + new THREE.MeshBasicMaterial({color: color}), + ) + this.scene.add(box) + return box + }, createBall: function (radius) { const ball = new THREE.Mesh(new THREE.SphereGeometry(radius), new THREE.MeshBasicMaterial({ color: 0xAA6611, @@ -245,14 +254,27 @@ self.throwables[event.data.id] = ball } } - if (event.code === && - ([value ?>, value ?>, value ?>].includes(event.data.type)) - ) { - const ball = self.showTrajectory ? self.createBall(self.throwables[event.data.extra.id]) : self.throwables[event.data.extra.id] - ball.visible = true - ball.position.set(event.data.position.x, event.data.position.y, -event.data.position.z) - if (!self.showTrajectory && event.data.type === value ?>) { - setTimeout(() => ball.visible = false, 2000) + if (event.code === ) { + if ([value ?>, value ?>, value ?>].includes(event.data.type)) { + const ball = self.showTrajectory ? self.createBall(self.throwables[event.data.extra.id]) : self.throwables[event.data.extra.id] + ball.visible = true + ball.position.set(event.data.position.x, event.data.position.y, -event.data.position.z) + if (!self.showTrajectory && event.data.type === value ?>) { + setTimeout(() => ball.visible = false, 2000) + } + } + if (event.data.type === value ?>) { + const color = new THREE.Color(`hsl(53, 100%, ${Math.random() * 70 + 20}%, 1)`) + let flame = self.createBox(event.data.extra.size, event.data.extra.height, event.data.extra.size, color) + if (self.flammable[event.data.extra.fire] === undefined) { + self.flammable[event.data.extra.fire] = {} + } + self.flammable[event.data.extra.fire][`${event.data.position.x}-${event.data.position.y}-${event.data.position.z}`] = flame + flame.position.set(event.data.position.x, event.data.position.y + (event.data.extra.height / 2), -event.data.position.z) + } + if (event.data.type === value ?>) { + const flame = self.flammable[event.data.extra.fire][`${event.data.position.x}-${event.data.position.y}-${event.data.position.z}`] + flame.visible = false } } })