BREAKOUT

par davidnurdin · Arcade
pas encore noté
▶ Jouer Sources Éditer GitHub
games/breakout / src / Scene / PlayScene.php · commit 8d554d3
<?phpnamespace App\Scene;use App\Entity\Ball;use App\Entity\Brick;use App\Entity\Paddle;use App\Level\Layouts;use JumPHP\Audio\Synth;use JumPHP\Loop\FixedStep;use JumPHP\Physics\Aabb;use JumPHP\Renderer\Renderer;use JumPHP\Scene\AbstractScene;use JumPHP\Ui\Hud;use JumPHP\Util\Lang;/** * A game: 3 lives, a wall of bricks per level. Params (enter): *   ['level' => 1]     first level (default 1) *   ['demo' => true]   the computer plays, endless (title background idea, headless smoke test) *   ['keep' => 5]      only keep the first N bricks of each wall (tests: reach the next level fast) */final class PlayScene extends AbstractScene{    public const HUD = 14;               // height of the score band    public const WALL = 4;               // thickness of the side / top walls    public const PADDLE_Y = 222;    private const BRICKS_TOP = 36;    private const LIVES = 3;    private const AUTO_LAUNCH = 3.0;     // the ball leaves the paddle by itself after this many seconds    private const CLEAR_DELAY = 1.6;     // "LEVEL n" message between two walls    private const ROW_PITCH = [988, 880, 784, 659, 587, 523, 440];    private Paddle $paddle;    private Ball $ball;    /** @var Brick[] */    private array $bricks = [];    private FixedStep $step;    private string $state = 'serve';     // serve (ball on the paddle), play, clear (next level), over    private float $timer = 0.0;    private float $time = 0.0;    private int $level = 1;    private int $lives = self::LIVES;    private int $score = 0;    private bool $demo = false;    private int $keep = 0;    private array $params = [];    public function enter(array $params = []): void    {        $this->params = $params;        $this->demo = !empty($params['demo']);        $this->keep = (int)($params['keep'] ?? 0);        $this->level = max(1, (int)($params['level'] ?? 1));        $this->lives = self::LIVES;        $this->score = 0;        $this->time = 0.0;        $this->step = new FixedStep(120);   // ball up to 330 px/s: < 3 px per step, no tunnelling through 8 px bricks        $this->paddle = new Paddle($this->width() / 2 - Paddle::WIDTH / 2, self::PADDLE_Y, self::WALL, $this->width() - self::WALL);        $this->ball = new Ball(0, 0);        $this->buildWall();        $this->serve();        $sound = $this->sound();        if (!$sound->hasSfx('paddle')) {            $sound->synth('paddle', Synth::tone(330, 0.06));            $sound->synth('wall', Synth::tone(220, 0.04, 'triangle'));            $sound->synth('steel', Synth::tone(1400, 0.05, 'triangle'));            foreach (self::ROW_PITCH as $i => $hz) {                $sound->synth('brick' . $i, Synth::tone($hz, 0.07));            }            $sound->synth('lose', Synth::sweep(500, 70, 0.6, 'saw'));            $sound->synth('clear', Synth::sequence([[523, 0.08], [659, 0.08], [784, 0.08], [1047, 0.25]]));            $sound->synth('over', Synth::sequence([[392, 0.15], [330, 0.15], [262, 0.15], [196, 0.4]], 'triangle'));        }    }    /** The wall of the current level. */    private function buildWall(): void    {        $this->bricks = [];        $pitch = (int)(($this->width() - 2 * self::WALL) / Layouts::COLUMNS);   // 24 px        foreach (Layouts::rows($this->level) as $row => $line) {            for ($col = 0; $col < strlen($line); $col++) {                $kind = $line[$col];                if (isset(Brick::KINDS[$kind])) {                    $x = self::WALL + $col * $pitch + ($pitch - Brick::WIDTH) / 2;                    $this->bricks[] = new Brick($x, self::BRICKS_TOP + $row * (Brick::HEIGHT + 2), $kind);                }            }        }        if ($this->keep > 0) {            $this->bricks = array_slice($this->bricks, -$this->keep);        }    }    /** The ball waits on the paddle. */    private function serve(): void    {        $this->state = 'serve';        $this->timer = self::AUTO_LAUNCH;        $this->ball->setVelocity(0, 0);        $this->stickBall();    }    private function stickBall(): void    {        $this->ball->setXY($this->paddle->centerX() - Ball::SIZE / 2, self::PADDLE_Y - Ball::SIZE - 1);    }    private function levelSpeed(): float    {        return min(260.0, 150.0 + 18.0 * ($this->level - 1));    }    public function update(float $dt): void    {        $this->time += $dt;        if ($this->actions()->isPressed('pause') && $this->state !== 'over') {            if ($this->demo) {                $this->scenes()->switchTo(TitleScene::class);            } else {                $this->scenes()->push(PauseScene::class);            }            return;        }        $this->controls($dt);        if ($this->state === 'serve') {            $this->stickBall();            $this->timer -= $dt;            if ($this->timer <= 0 || (!$this->demo && $this->actions()->isPressed('launch'))) {                $this->state = 'play';                $this->ball->launch($this->levelSpeed());                $this->sound()->playSfx('paddle');            }        } elseif ($this->state === 'play') {            $this->step->run($dt, fn (float $h) => $this->simulate($h));        } elseif ($this->state === 'clear' || $this->state === 'over') {            $this->timer -= $dt;            if ($this->timer <= 0) {                $this->state === 'clear' ? $this->nextLevel() : $this->enter($this->params);   // 'over' only in the demo            }        }    }    private function controls(float $dt): void    {        if ($this->demo) {            $this->paddle->moveTowards($this->ball->centerX() - $this->demoAim() * Paddle::WIDTH / 2, $dt);            return;        }        $touch = $this->engine->input->getTouch();        if ($touch->active) {            $this->paddle->moveTowards($touch->x * $this->width(), $dt);        } else {            $this->paddle->move($this->actions()->axis('left', 'right'), $dt);        }    }    /** Demo: which part of the paddle (-1..1) sends the ball towards the lowest brick. */    private function demoAim(): float    {        $target = null;        foreach ($this->bricks as $brick) {            if ($target === null || $brick->getFloatY() > $target->getFloatY()) {                $target = $brick;            }        }        if ($target === null) {            return 0.0;        }        $dx = $target->centerX() - $this->ball->centerX();        $dy = max(1.0, self::PADDLE_Y - $target->centerY());        return max(-0.9, min(0.9, atan2($dx, $dy) / deg2rad(60)));    }    /** One physics step: x then y, so a brick knows which face was hit. */    private function simulate(float $h): void    {        if ($this->state !== 'play') {            return;        }        $ball = $this->ball;        [$vx, $vy] = $ball->getVelocity();        $right = $this->width() - self::WALL;        $ball->setX($ball->getFloatX() + $vx * $h);        if (($ball->getFloatX() < self::WALL && $vx < 0) || ($ball->getFloatX() + Ball::SIZE > $right && $vx > 0)) {            $ball->setX($vx < 0 ? self::WALL : $right - Ball::SIZE);            $ball->setSpeedX(-$vx);            $this->sound()->playSfx('wall');        } elseif ($this->hitBrick()) {            $ball->setX($ball->getFloatX() - $vx * $h);            $ball->setSpeedX(-$vx);        }        $ball->setY($ball->getFloatY() + $vy * $h);        if ($ball->getFloatY() < self::HUD + self::WALL && $vy < 0) {            $ball->setY(self::HUD + self::WALL);            $ball->setSpeedY(-$vy);            $this->sound()->playSfx('wall');        } elseif ($this->hitBrick()) {            $ball->setY($ball->getFloatY() - $vy * $h);            $ball->setSpeedY(-$vy);        } elseif ($vy > 0 && Aabb::intersects($ball->getBoundingBox(), $this->paddle->getBoundingBox())            && $ball->getFloatY() + Ball::SIZE - $vy * $h <= self::PADDLE_Y + 2) {            // the hit point on the paddle sets the angle: the edges send the ball sideways            $offset = ($ball->centerX() - $this->paddle->centerX()) / (Paddle::WIDTH / 2);            $ball->setY(self::PADDLE_Y - Ball::SIZE);            $ball->aim($offset);            $this->sound()->playSfx('paddle');        }        if ($ball->getFloatY() > $this->height()) {            $this->loseLife();        }    }    /** The first brick under the ball takes a hit. */    private function hitBrick(): bool    {        $box = $this->ball->getBoundingBox();        foreach ($this->bricks as $i => $brick) {            if (!Aabb::intersects($box, $brick->getBoundingBox())) {                continue;            }            if ($brick->hit()) {                $this->score += $brick->points;                $row = (int)(($brick->getFloatY() - self::BRICKS_TOP) / (Brick::HEIGHT + 2));                $this->sound()->playSfx('brick' . min($row, count(self::ROW_PITCH) - 1));                unset($this->bricks[$i]);                $this->ball->speedUp(1.012);                if ($this->bricks === []) {                    $this->levelCleared();                }            } else {                $this->sound()->playSfx('steel');            }            return true;        }        return false;    }    private function levelCleared(): void    {        $this->state = 'clear';        $this->timer = self::CLEAR_DELAY;        $this->score += 500;        $this->sound()->playSfx('clear');    }    private function nextLevel(): void    {        $this->level++;        $this->buildWall();        $this->serve();    }    private function loseLife(): void    {        $this->lives--;        $this->sound()->playSfx('lose');        if ($this->lives > 0) {            $this->serve();            return;        }        $this->state = 'over';        $this->timer = 2.5;        $this->sound()->playSfx('over');        if ($this->demo) {            return;                              // restarts by itself (update)        }        $options = $this->engine->options;        $best = (int)$options->get('best', 0);        if ($this->score > $best) {            $options->set('best', $this->score);        }        $this->scenes()->push(GameOverScene::class, [            'score' => $this->score, 'level' => $this->level, 'record' => $this->score > $best, 'again' => $this->params,        ]);    }    // --- drawing ---------------------------------------------------------------------------------    public function render(Renderer $r): void    {        $w = $this->width();        $font = $this->font();        $r->clear([8, 8, 24]);        // the walls        $r->fillRect(0, self::HUD, $w, self::WALL, [90, 90, 110]);        $r->fillRect(0, self::HUD, self::WALL, $this->height() - self::HUD, [90, 90, 110]);        $r->fillRect($w - self::WALL, self::HUD, self::WALL, $this->height() - self::HUD, [90, 90, 110]);        foreach ($this->bricks as $brick) {            $brick->render($r);        }        $this->paddle->render($r);        if ($this->state === 'play' || $this->state === 'serve') {            $this->ball->render($r);        }        // HUD: score, level, lives (small paddles)        $font->draw($r, Lang::t('SCORE {n}', ['n' => $this->score]), 4, 4, 1, [255, 255, 255]);        $font->draw($r, $this->demo ? Lang::t('DEMO') : Lang::t('LEVEL {n}', ['n' => $this->level]), $w / 2, 4, 1, [255, 220, 120], 'center');        for ($i = 0; $i < $this->lives; $i++) {            $r->fillRect($w - 14 - $i * 14, 5, 10, 4, [200, 200, 220]);        }        $cy = $this->height() * 0.62;        if ($this->state === 'clear') {            Hud::message($r, $font, Lang::t('LEVEL {n}', ['n' => $this->level + 1]), $w / 2, $cy, 2);        } elseif ($this->state === 'serve') {            $font->draw($r, Lang::t('LEVEL {n}', ['n' => $this->level]), $w / 2, $cy - 14, 2, [255, 255, 255], 'center', [0, 0, 0]);            if (!$this->demo && Hud::blink($this->time)) {                $font->draw($r, Lang::t('{key} TO LAUNCH', ['key' => strtoupper($this->actions()->label('launch'))]), $w / 2, $cy + 6, 1,                    [200, 200, 200], 'center', [0, 0, 0]);            }        } elseif ($this->state === 'over' && $this->demo) {   // a player sees GameOverScene instead            Hud::message($r, $font, Lang::t('GAME OVER'), $w / 2, $cy, 2);        }    }}