RAINBOW ISLANDS

by davidnurdin · Remakes
not rated yet
▶ Play Source Edit GitHub
games/rainbow-island / src / Scene / PlayScene.php · commit b077351
<?phpnamespace App\Scene;use App\Arcade\ArcadeFont;use App\Arcade\Artwork;use App\Entity\Boss;use App\Entity\Bub;use App\Entity\Enemy;use App\Entity\Item;use App\Entity\Rainbow;use App\Game\ScoreBoard;use App\Game\Session;use App\World\Round;use App\World\Terrain;use JumPHP\Physics\Aabb;use JumPHP\Renderer\Renderer;use JumPHP\Scene\AbstractScene;/** * One round: climb from the bottom to the goal with the help of the rainbows, before the water rises. * * Phases: 'ready' (READY !), 'play', 'dying', 'goal' (GOAL IN!), and on the 4th round of an island * 'boss' (the camera rises to the arena above the goal) then 'completed' (COMPLETED!). * * The view is 256 x 176 like the CPC screen: 16 lines of scores, then 160 lines of playfield. * $cameraY is the world y shown at the top of the playfield (world y grows downwards, 0 = bottom of the round). * * Params: round (1-28, default: the session's), demo (a bot plays: the smoke test), new (a new game), * arena (tests: Bub starts at the goal, on a boss round right in front of the boss). */final class PlayScene extends AbstractScene{    private const FIELD_HEIGHT = 160;    private const MAX_RAINBOWS = 3;    private const READY_TIME = 2.0;    private const DEATH_TIME = 2.6;    private const GOAL_TIME = 4.0;    private const HURRY_MESSAGE_TIME = 2.0;    private const WATER_DELAY = 4.0;    private const WATER_SPEED = 9.0;    private Round $round;    private Terrain $terrain;    private mixed $background;    private Bub $bub;    /** @var Enemy[] */    private array $enemies = [];    /** @var Item[] */    private array $items = [];    private ?Boss $boss = null;    private string $phase = 'ready';    private float $phaseTime = 0.0;    /** Time since the round (or the last life) started: the water comes after the round's hurry time. */    private float $clock = 0.0;    private bool $hurry = false;    private ?float $waterY = null;    private float $cameraY = 0.0;    private bool $demo = false;    private float $demoTime = 0.0;    private int $demoMove = 1;    public function __construct(private Artwork $artwork, private Session $session, private ScoreBoard $scoreBoard)    {    }    public function enter(array $params = []): void    {        $this->artwork->boot();        $this->demo = !empty($params['demo']);        if ($this->demo || !empty($params['new'])) {            $this->session->start((int)($params['round'] ?? 1));        } elseif (isset($params['round'])) {            $this->session->round = (int)$params['round'];        }        $this->round = $this->artwork->loadRound($this->session->round);        $this->terrain = new Terrain($this->round);        $this->background = $this->artwork->roundTexture($this->round);        $this->bub = new Bub(80, -72);        if (!empty($params['arena'])) {            $this->bub->setXY(80, $this->round->goalY() - Bub::SIZE - 16);            $this->bub->onGround = false;        }        $this->boss = null;        $this->items = [];        $this->enemies = [];        foreach ($this->round->objects as $object) {            $kind = $this->round->island->enemy($object['type']);            $this->enemies[] = new Enemy($object['x'] * Round::TILE, $object['y'] * Round::TILE, $kind['frames'], $kind['flies'],                $object['flip'], $this->artwork, $this->round->island->number);        }        if ($this->round->number % 4 === 1) {            $this->session->diamonds = [];        }        $this->restartClock();        $this->cameraY = $this->cameraTarget();        $this->setPhase('ready');        $this->sound()->playMusic('theme');    }    public function exit(): void    {        $this->session->saveHiScore();    }    private function setPhase(string $phase): void    {        $this->phase = $phase;        $this->phaseTime = 0.0;    }    private function restartClock(): void    {        $this->clock = 0.0;        $this->hurry = false;        $this->waterY = null;        $this->terrain->rainbows = [];    }    public function update(float $dt): void    {        if ($this->actions()->isPressed('pause') && !$this->demo) {            $this->scenes()->push(PauseScene::class);            return;        }        $this->phaseTime += $dt;        switch ($this->phase) {            case 'ready':                if ($this->phaseTime >= self::READY_TIME) {                    $this->setPhase('play');                }                break;            case 'play':            case 'boss':                $this->play($dt);                break;            case 'dying':                $this->dying($dt);                break;            case 'goal':                $this->updateEnemies($dt);                $this->updateItems($dt);                if ($this->phaseTime >= self::GOAL_TIME) {                    $this->nextRound();                }                break;            case 'completed':                $this->updateItems($dt);                if ($this->phaseTime >= self::GOAL_TIME) {                    $this->nextRound();                }                break;        }        $this->cameraY += ($this->cameraTarget() - $this->cameraY) * min(1.0, $dt * 5);    }    private function play(float $dt): void    {        $this->clock += $dt;        [$move, $jump, $fire] = $this->controls($dt);        $event = $this->bub->step($dt, $move, $jump, $this->terrain);        if ($event === 'jump') {            $this->sound()->playSfx('jump');        } elseif ($event instanceof Rainbow) {            $this->breakRainbow($event);        }        if ($fire) {            $this->fireRainbow();        }        $this->updateRainbows($dt);        $this->updateEnemies($dt);        $this->updateItems($dt);        if ($this->boss !== null) {            $this->updateBoss($dt);        }        $this->updateWater($dt);        if ($this->phase === 'play' && $this->bub->onGround && $this->bub->getFloatY() <= $this->round->goalY() + 4) {            $this->reachGoal();        }    }    /** @return array{int, bool, bool} move, jump, fire */    private function controls(float $dt): array    {        if (!$this->demo) {            $actions = $this->actions();            return [(int)$actions->axis('left', 'right'), $actions->isPressed('jump'), $actions->isPressed('fire')];        }        // the bot of the smoke test: walks, fires a rainbow, climbs it, jumps        $this->demoTime += $dt;        $x = $this->bub->getFloatX();        if ($x <= 0 || $x >= Round::WIDTH - Bub::SIZE || fmod($this->demoTime, 5.0) < $dt) {            $this->demoMove = $x <= 0 ? 1 : ($x >= Round::WIDTH - Bub::SIZE ? -1 : -$this->demoMove);        }        $beat = fmod($this->demoTime, 1.6);        return [$beat < 0.3 ? 0 : $this->demoMove, $beat >= 1.2 && $beat - $dt < 1.2, $beat < $dt];    }    private function fireRainbow(): void    {        $bub = $this->bub;        if ($bub->isDying()) {            return;        }        $right = $bub->getFacing() === 'right';        // like the original, the arc is aligned on the tiles, its feet level with Bub's        $x = $right ? ceil(($bub->getFloatX() + 16) / Round::TILE) * Round::TILE : floor(($bub->getFloatX() + 8 - Rainbow::WIDTH) / Round::TILE) * Round::TILE;        $x = max(0, min(Round::WIDTH - Rainbow::WIDTH, $x));        $y = round(($bub->feet() - Rainbow::HEIGHT) / Round::TILE) * Round::TILE;        $this->terrain->rainbows[] = new Rainbow($x, $y, $bub->getFacing(), $this->artwork->rainbowTiles, $this->artwork->rainbowProfile);        if (count($this->terrain->rainbows) > self::MAX_RAINBOWS) {            array_shift($this->terrain->rainbows);        }        $bub->fire();        $this->sound()->playSfx('rainbow');    }    private function breakRainbow(Rainbow $rainbow): void    {        if (!$rainbow->isFalling()) {            $rainbow->breakDown();            $this->sound()->playSfx('break');        }    }    private function updateRainbows(float $dt): void    {        $bottom = $this->cameraY + self::FIELD_HEIGHT + 32;        foreach ($this->terrain->rainbows as $i => $rainbow) {            $rainbow->update($dt);            if ($rainbow->dead || $rainbow->getFloatY() > $bottom) {                unset($this->terrain->rainbows[$i]);                continue;            }            if (!$rainbow->isGrowing() && !$rainbow->isFalling()) {                continue;            }            $box = $rainbow->getBoundingBox();            foreach ($this->enemies as $enemy) {                if ($enemy->isHarmful() && $this->isNear($enemy) && Aabb::intersects($box, $enemy->hitBox())) {                    $this->knockOut($enemy, $rainbow);                }            }            // a falling rainbow breaks the rainbows it falls on            if ($rainbow->isFalling()) {                foreach ($this->terrain->rainbows as $other) {                    if ($other !== $rainbow && !$other->isFalling() && Aabb::intersects($box, $other->getBoundingBox())) {                        $this->breakRainbow($other);                    }                }            }            if ($this->boss !== null && !$this->boss->isBeaten() && !isset($rainbow->hits['boss'])                && Aabb::intersects($box, $this->boss->hitBox())) {                $rainbow->hits['boss'] = true;                $this->boss->hit();                $this->sound()->playSfx('hit');                $this->addScore($this->boss->isBeaten() ? 10000 : 100);            }        }        $this->terrain->rainbows = array_values($this->terrain->rainbows);    }    /**     * A rainbow that grows over an enemy turns it into a fruit; a falling one into a small diamond whose     * colour is given by the column of the screen (seven bands, left to right), like the original.     */    private function knockOut(Enemy $enemy, Rainbow $rainbow): void    {        if ($rainbow->isFalling()) {            $band = min(Item::DIAMONDS - 1, (int)floor($enemy->centerX() * Item::DIAMONDS / Round::WIDTH));            $item = Item::FIRST_DIAMOND + $band;        } else {            $item = mt_rand(0, Item::FRUITS - 1);        }        $enemy->knockOut($rainbow->centerX(), $item);        $this->sound()->playSfx('hit');        $this->addScore(100);    }    private function updateEnemies(float $dt): void    {        $bub = $this->bub;        foreach ($this->enemies as $i => $enemy) {            if (!$this->isNear($enemy)) {                continue;            }            $item = $enemy->step($dt, $this->terrain);            if ($item !== null) {                $this->items[] = $item;            }            if ($enemy->dead) {                unset($this->enemies[$i]);            } elseif ($enemy->isHarmful() && !$bub->isDying() && $bub->invulnerable <= 0                && Aabb::intersects($bub->hitBox(), $enemy->hitBox())) {                $this->loseLife();            }        }    }    /** Enemies wait until the camera comes near them. */    private function isNear(Enemy $enemy): bool    {        $y = $enemy->getFloatY();        return $y > $this->cameraY - 48 && $y < $this->cameraY + self::FIELD_HEIGHT + 48;    }    private function updateItems(float $dt): void    {        foreach ($this->items as $i => $item) {            $item->step($dt, $this->terrain);            if (!$item->dead && !$this->bub->isDying() && Aabb::intersects($this->bub->getBoundingBox(), $item->getBoundingBox())) {                $item->kill();                $this->sound()->playSfx($item->isDiamond() ? 'bonus' : 'collect');                if ($item->isDiamond()) {                    $this->session->diamonds[$item->kind - Item::FIRST_DIAMOND] = true;                }                $this->addScore($item->points());            }            if ($item->dead) {                unset($this->items[$i]);            }        }    }    private function updateBoss(float $dt): void    {        $boss = $this->boss;        $boss->update($dt);        if (!$boss->isBeaten() && !$this->bub->isDying() && $this->bub->invulnerable <= 0            && Aabb::intersects($this->bub->hitBox(), $boss->hitBox())) {            $this->loseLife();        }        if ($boss->isBeaten() && $boss->getFloatY() > $this->cameraY + self::FIELD_HEIGHT) {            $this->completeIsland();        }    }    private function updateWater(float $dt): void    {        $hurryAt = max(20, $this->round->hurrySeconds);        if (!$this->hurry && $this->clock >= $hurryAt && $this->boss === null) {            $this->hurry = true;            $this->sound()->playMusic('hurry');        }        if ($this->hurry && $this->clock >= $hurryAt + self::WATER_DELAY) {            $this->waterY = ($this->waterY ?? 0.0) - self::WATER_SPEED * $dt;            if ($this->bub->feet() - 6 > $this->waterY && !$this->bub->isDying()) {                $this->sound()->playSfx('splash');                $this->loseLife();            }        }    }    private function addScore(float $points): void    {        if ($this->session->addScore($points)) {            $this->sound()->playSfx('extralife');        }    }    private function loseLife(): void    {        $this->bub->die();        $this->setPhase('dying');        $this->sound()->playMusic('death', false);    }    private function dying(float $dt): void    {        $this->bub->step($dt, 0, false, $this->terrain);        if ($this->phaseTime < self::DEATH_TIME) {            return;        }        if ($this->session->lives <= 0) {            $this->session->saveHiScore();            if ($this->demo) {                $this->enter(['demo' => true, 'round' => $this->round->number]);                return;            }            $this->scenes()->push(GameOverScene::class);            return;        }        $this->session->lives--;        $this->bub->revive();        $this->restartClock();        $this->setPhase($this->boss !== null ? 'boss' : 'play');        $this->sound()->playMusic($this->boss !== null ? 'boss' : 'theme');    }    private function reachGoal(): void    {        if ($this->round->isBossRound()) {            // the arena is the 12 rows above the goal: the camera rises there and the boss comes down            $this->setPhase('boss');            $this->hurry = false;            $this->waterY = null;            $this->boss = new Boss($this->round->topY(), $this->artwork, $this->round->island);            $this->sound()->playMusic('boss');            return;        }        $this->setPhase('goal');        $this->waterY = null;        // the enemies still around turn into fruits        foreach ($this->enemies as $enemy) {            if ($enemy->isHarmful() && $this->isNear($enemy)) {                $enemy->knockOut($enemy->centerX(), mt_rand(0, Item::FRUITS - 1));            }        }        $this->sound()->playMusic('goal', false);    }    private function completeIsland(): void    {        $this->boss = null;        $this->setPhase('completed');        if (count($this->session->diamonds) === Item::DIAMONDS) {            $this->session->bigDiamonds[$this->round->island->number] = true;        }        $this->sound()->playMusic('goal', false);    }    private function nextRound(): void    {        if ($this->demo) {            $this->enter(['demo' => true, 'round' => $this->round->number % Session::LAST_ROUND + 1]);            return;        }        if ($this->round->number >= Session::LAST_ROUND) {            $this->scenes()->switchTo(EndingScene::class);            return;        }        $this->scenes()->switchTo(PlayScene::class, ['round' => $this->round->number + 1]);    }    /** Where the camera wants to be: Bub a bit under the middle, the boss arena fixed at the top. */    private function cameraTarget(): float    {        $top = $this->round->topY();        if ($this->phase === 'boss' || $this->phase === 'completed') {            return $top;        }        return max($top, min(-self::FIELD_HEIGHT, $this->bub->getFloatY() - 84));    }    public function render(Renderer $r): void    {        $r->clear([0, 0, 0]);        $island = $this->round->island->number;        $cameraY = round($this->cameraY);        // world y -> screen y        $viewY = $cameraY - ScoreBoard::HEIGHT;        $r->copy($this->background, [0, $cameraY - $this->round->topY(), Round::WIDTH, self::FIELD_HEIGHT], 0, ScoreBoard::HEIGHT);        foreach ($this->terrain->rainbows as $rainbow) {            $rainbow->draw($r, $this->artwork, $island, $viewY);        }        foreach ($this->items as $item) {            $item->draw($r, $this->artwork, $island, $viewY);        }        foreach ($this->enemies as $enemy) {            if ($this->isNear($enemy)) {                $enemy->draw($r, $viewY);            }        }        $this->boss?->draw($r, $viewY);        $this->bub->draw($r, $this->artwork, $island, $viewY);        if ($this->waterY !== null) {            $this->drawWater($r, $this->waterY - $viewY);        }        $this->scoreBoard->render($r, $this->font());        $this->scoreBoard->renderBottom($r);        if ($this->boss !== null) {            $this->drawBossEnergy($r);        }        $this->drawMessage($r);    }    private function drawWater(Renderer $r, float $y): void    {        $y = max(ScoreBoard::HEIGHT, $y);        $r->fillRect(0, $y, $r->getWidth(), $r->getHeight() - $y, [0, 64, 255, 150]);        for ($x = 0; $x < $r->getWidth(); $x += 16) {            $r->fillRect($x + fmod($this->clock * 12, 16), $y, 8, 2, [128, 255, 255]);        }    }    private function drawBossEnergy(Renderer $r): void    {        $r->fillRect(96, ScoreBoard::HEIGHT + 2, 64, 6, [0, 0, 0]);        $r->fillRect(97, ScoreBoard::HEIGHT + 3, 62 * $this->boss->energy / Boss::ENERGY, 4, [255, 0, 0]);    }    private function drawMessage(Renderer $r): void    {        $font = $this->font();        $cx = $r->getWidth() / 2;        $cy = ScoreBoard::HEIGHT + self::FIELD_HEIGHT / 2;        switch ($this->phase) {            case 'ready':                if ($this->round->number % 4 === 1) {                    $font->draw($r, $this->round->island->name, $cx, $cy - 24, 1, ArcadeFont::CYAN, 'center');                }                $font->draw($r, 'ROUND ' . $this->round->number, $cx, $cy - 12, 1, ArcadeFont::WHITE, 'center');                $font->draw($r, 'READY !', $cx, $cy, 1, ArcadeFont::YELLOW, 'center');                break;            case 'goal':                $font->draw($r, 'GOAL IN!', $cx, $cy, 1, ArcadeFont::YELLOW, 'center');                break;            case 'completed':                $font->draw($r, 'COMPLETED!', $cx, $cy, 1, ArcadeFont::YELLOW, 'center');                break;        }        if ($this->hurry && $this->clock - max(20, $this->round->hurrySeconds) < self::HURRY_MESSAGE_TIME && $this->phase === 'play') {            $font->draw($r, 'HURRY!', $cx, $cy, 1, ArcadeFont::RED, 'center');        }    }}