UK
games/open-lemmings / src / Sim / World.php · commit 04e2e2a
<?phpnamespace App\Sim;/** * A level being played: the terrain, the Lumikins, the rules. Pure PHP, deterministic, one step() = one tick * (30 ticks a second). The scene draws it and turns its $events into particles and sounds. */final class World{    public const TICKS_PER_SECOND = 30;    public const SKILLS = ['climb', 'glide', 'boom', 'stop', 'bridge', 'bash', 'mine', 'dig'];    public const SAFE_FALL = 88;    public const FUSE_TICKS = 90;    public const MAX_BRICKS = 12;    public const HATCH_DELAY = 40;    /** @var Lumi[] */    public array $lumis = [];    public int $tick = 0;    public int $spawned = 0;    public int $saved = 0;    public int $lost = 0;    public int $total = 10;    public int $need = 5;    /** release rate 1..99 (the higher, the faster they come out) */    public int $rate = 50;    public int $minRate = 1;    public int $ticksLeft = 0;    public bool $nuked = false;    /** @var array<string, int> skill => how many are left */    public array $skills = [];    /** @var array<int, array<int, int>> deadly pools [x0, x1, top row, bottom row (optional: to the bottom of the level)] */    public array $liquids = [];    /** hatch: where they come out [x, y] */    public array $hatch = [40, 40];    /** exit: [x, y of its ground] */    public array $exit = [400, 200];    /** @var array<int, array{0:string,1:int,2:int,3:mixed}> what happened this tick: [type, x, y, extra] */    public array $events = [];    private int $nextSpawn = self::HATCH_DELAY;    private int $nextId = 0;    private int $nukeIndex = 0;    public function __construct(public Terrain $terrain)    {        foreach (self::SKILLS as $skill) {            $this->skills[$skill] = 0;        }    }    public function alive(): int    {        $n = 0;        foreach ($this->lumis as $l) {            $n += $l->alive ? 1 : 0;        }        return $n;    }    public function isOver(): bool    {        if ($this->ticksLeft <= 0) {            return true;        }        return ($this->spawned >= $this->total || $this->nuked) && $this->alive() === 0;    }    public function isWon(): bool    {        return $this->saved >= $this->need;    }    public function spawnInterval(): int    {        return max(4, 104 - $this->rate);    }    public function changeRate(int $delta): void    {        $this->rate = max($this->minRate, min(99, $this->rate + $delta));    }    /** Everybody gets a fuse, one after the other; nobody else comes out. */    public function nuke(): void    {        if (!$this->nuked) {            $this->nuked = true;            $this->events[] = ['nuke', 0, 0, null];        }    }    public function step(): void    {        $this->tick++;        if ($this->ticksLeft > 0) {            $this->ticksLeft--;        }        if (!$this->nuked && $this->spawned < $this->total && $this->tick >= $this->nextSpawn) {            $l = new Lumi($this->nextId++, $this->hatch[0], $this->hatch[1], 1);            $this->lumis[] = $l;            $this->spawned++;            $this->nextSpawn = $this->tick + $this->spawnInterval();            $this->events[] = ['spawn', $l->x, $l->y, null];        }        if ($this->nuked && $this->tick % 2 === 0) {            for (; $this->nukeIndex < count($this->lumis); $this->nukeIndex++) {                $l = $this->lumis[$this->nukeIndex];                if ($l->alive && $l->fuse < 0 && !$l->is(Lumi::LEAVE)) {                    $l->fuse = self::FUSE_TICKS;                    $this->nukeIndex++;                    break;                }            }        }        foreach ($this->lumis as $l) {            if ($l->alive) {                $this->update($l);            }        }    }    // --- orders --------------------------------------------------------------------------------------------    public function canAssign(Lumi $l, string $skill): bool    {        if (!$l->alive || $l->is(Lumi::LEAVE) || ($this->skills[$skill] ?? 0) <= 0) {            return false;        }        $grounded = $l->is(Lumi::WALK, Lumi::BRIDGE, Lumi::BASH, Lumi::MINE, Lumi::DIG);        return match ($skill) {            'climb' => !$l->climber,            'glide' => !$l->glider,            'boom' => $l->fuse < 0,            'stop' => $grounded,            'bridge' => $grounded && !$l->is(Lumi::BRIDGE),            'bash' => $grounded && !$l->is(Lumi::BASH),            'mine' => $grounded && !$l->is(Lumi::MINE),            'dig' => $grounded && !$l->is(Lumi::DIG),            default => false,        };    }    public function assign(Lumi $l, string $skill): bool    {        if (!$this->canAssign($l, $skill)) {            return false;        }        switch ($skill) {            case 'climb': $l->climber = true; break;            case 'glide': $l->glider = true; break;            case 'boom': $l->fuse = self::FUSE_TICKS; break;            case 'stop': $l->set(Lumi::STOP); break;            case 'bridge': $l->set(Lumi::BRIDGE); $l->bricks = 0; break;            case 'bash': $l->set(Lumi::BASH); break;            case 'mine': $l->set(Lumi::MINE); break;            case 'dig': $l->set(Lumi::DIG); break;        }        $this->skills[$skill]--;        $this->events[] = ['assign', $l->x, $l->y, $skill];        return true;    }    /** The Lumikin under a point of the level: first one that can take $skill, the nearest. */    public function pick(float $x, float $y, ?string $skill = null, int $reach = 8): ?Lumi    {        $best = null;        $bestScore = 1e9;        foreach ($this->lumis as $l) {            if (!$l->alive || $l->is(Lumi::LEAVE)) {                continue;            }            $dx = abs($l->x - $x);            $dy = abs(($l->y - 6) - $y);            if ($dx > $reach || $dy > $reach + 3) {                continue;            }            $score = $dx + $dy + ($skill !== null && !$this->canAssign($l, $skill) ? 1000 : 0);            if ($score < $bestScore) {                $bestScore = $score;                $best = $l;            }        }        return $best;    }    // --- one Lumikin, one tick -----------------------------------------------------------------------------    private function update(Lumi $l): void    {        $l->frame++;        if ($l->fuse >= 0 && --$l->fuse < 0) {            $this->terrain->digCircle($l->x, $l->y - 5, 13);            $this->kill($l, 'boom');            return;        }        switch ($l->state) {            case Lumi::WALK: $this->walk($l); break;            case Lumi::FALL:            case Lumi::GLIDE: $this->fall($l); break;            case Lumi::CLIMB: $this->climb($l); break;            case Lumi::STOP:                if (!$this->terrain->anySolid($l->x, $l->y, 1, 2)) {                    $this->startFall($l);                }                break;            case Lumi::BRIDGE: $this->bridge($l); break;            case Lumi::BASH: $this->bash($l); break;            case Lumi::MINE: $this->mine($l); break;            case Lumi::DIG: $this->dig($l); break;            case Lumi::LEAVE:                if ($l->frame >= 20) {                    $l->alive = false;                    $this->saved++;                    $this->events[] = ['saved', $l->x, $l->y, null];                }                return;        }        if (!$l->alive) {            return;        }        if ($l->y > $this->terrain->height + 14) {            $this->kill($l, 'gone');            return;        }        foreach ($this->liquids as $pool) {            [$x0, $x1, $top] = $pool;            if ($l->x >= $x0 && $l->x <= $x1 && $l->y >= $top + 4 && $l->y <= ($pool[3] ?? 9999)) {                $this->kill($l, 'drown');                return;            }        }        if (!$l->is(Lumi::FALL, Lumi::CLIMB) && abs($l->x - $this->exit[0]) <= 3 && abs($l->y - $this->exit[1]) <= 7) {            $l->set(Lumi::LEAVE);            $l->fuse = -1;            $this->events[] = ['leave', $l->x, $l->y, null];        }    }    private function kill(Lumi $l, string $how): void    {        $l->alive = false;        $this->lost++;        $this->events[] = [$how, $l->x, $l->y, null];    }    private function startFall(Lumi $l): void    {        $l->set(Lumi::FALL);        $l->fall = 0;    }    private function walk(Lumi $l): void    {        $t = $this->terrain;        // a stopper ahead turns it around        foreach ($this->lumis as $b) {            if ($b->alive && $b->state === Lumi::STOP && $b !== $l && abs($b->y - $l->y) <= 10) {                $d = $b->x - $l->x;                if ($d * $l->dir > 0 && abs($d) <= 5) {                    $l->dir = -$l->dir;                    return;                }            }        }        $nx = $l->x + $l->dir;        if ($t->solid($nx, $l->y - 1)) {            $up = 0;            for ($u = 1; $u <= 6; $u++) {                if (!$t->solid($nx, $l->y - 1 - $u)) {                    $up = $u;                    break;                }            }            if ($up === 0) {                if ($l->climber && $nx >= 0 && $nx < $t->width) {                    $l->set(Lumi::CLIMB);                } else {                    $l->dir = -$l->dir;                }                return;            }            $l->x = $nx;            $l->y -= $up;            return;        }        $l->x = $nx;        $d = 0;        while ($d <= 4 && !$t->solid($nx, $l->y + $d)) {            $d++;        }        if ($d > 4) {            $this->startFall($l);        } else {            $l->y += $d;        }    }    private function fall(Lumi $l): void    {        $t = $this->terrain;        if ($l->state === Lumi::FALL && $l->glider && $l->fall >= 18) {            $l->state = Lumi::GLIDE;            $l->frame = 0;        }        $speed = $l->state === Lumi::GLIDE ? 1 : 3;        for ($i = 0; $i < $speed; $i++) {            if ($t->solid($l->x, $l->y)) {                if ($l->state === Lumi::FALL && $l->fall > self::SAFE_FALL) {                    $this->kill($l, 'splat');                    return;                }                $l->set(Lumi::WALK);                $l->fall = 0;                return;            }            $l->y++;            $l->fall++;        }    }    private function climb(Lumi $l): void    {        $t = $this->terrain;        if ($l->frame % 2 === 0) {            return;        }        $wall = $l->x + $l->dir;        if (!$t->solid($wall, $l->y - 1)) {            $l->x = $wall;            $l->set(Lumi::WALK);            return;        }        if ($t->solid($l->x, $l->y - 12)) {            $l->dir = -$l->dir;            $this->startFall($l);            return;        }        $l->y--;    }    private function bridge(Lumi $l): void    {        $t = $this->terrain;        if ($l->frame % 9 !== 0) {            return;        }        if ($l->bricks >= self::MAX_BRICKS) {            $l->set(Lumi::WALK);            return;        }        $ahead = $l->dir > 0 ? $l->x + 1 : $l->x - 3;        if ($t->anySolid($ahead, $l->y - 11, 3, 9)) {            $l->dir = -$l->dir;            $l->set(Lumi::WALK);            return;        }        $x0 = $l->dir > 0 ? $l->x + 1 : $l->x - 6;        $t->buildSpan($l->y - 1, $x0, $x0 + 5);        $t->buildSpan($l->y, $x0, $x0 + 5);        $l->bricks++;        $l->x += 2 * $l->dir;        $l->y--;        $this->events[] = [$l->bricks > self::MAX_BRICKS - 3 ? 'lastbrick' : 'brick', $l->x, $l->y, null];    }    private function bash(Lumi $l): void    {        $t = $this->terrain;        if (!$t->anySolid($l->x, $l->y, 1, 3)) {            $this->startFall($l);            return;        }        if ($l->frame % 2 !== 0) {            return;        }        $front = $l->dir > 0 ? $l->x + 1 : $l->x - 6;        if ($t->anySteel($front, $l->y - 10, 6, 10)) {            $this->events[] = ['clink', $l->x, $l->y, null];            $l->set(Lumi::WALK);            return;        }        if (!$t->anySolid($front, $l->y - 10, 6, 10)) {            $far = $l->dir > 0 ? $l->x + 1 : $l->x - 14;            if (!$t->anySolid($far, $l->y - 10, 14, 10)) {                $l->set(Lumi::WALK);                return;            }        } else {            $t->digRect($front, $l->y - 10, 6, 10);            if ($l->work % 3 === 0) {                $this->events[] = ['dust', $l->x + 5 * $l->dir, $l->y - 5, null];            }        }        $l->work++;        $l->x += $l->dir;        if (!$t->solid($l->x, $l->y)) {            $d = 1;            while ($d <= 3 && !$t->solid($l->x, $l->y + $d)) {                $d++;            }            if ($d <= 3) {                $l->y += $d;            }        }    }    private function mine(Lumi $l): void    {        $t = $this->terrain;        if ($l->frame % 3 !== 0) {            return;        }        $down = $l->work % 2 === 1 ? 1 : 0;        $front = $l->dir > 0 ? $l->x - 1 : $l->x - 6;        if ($t->anySteel($front, $l->y - 10, 8, 10 + $down)) {            $this->events[] = ['clink', $l->x, $l->y, null];            $l->set(Lumi::WALK);            return;        }        $t->digRect($front, $l->y - 10, 8, 10 + $down);        if ($l->work % 2 === 0) {            $this->events[] = ['dust', $l->x + 4 * $l->dir, $l->y - 3, null];        }        $l->work++;        $l->x += $l->dir;        $l->y += $down;        if (!$t->solid($l->x, $l->y)) {            $d = 1;            while ($d <= 3 && !$t->solid($l->x, $l->y + $d)) {                $d++;            }            if ($d > 3) {                $this->startFall($l);            } else {                $l->y += $d;            }        }    }    private function dig(Lumi $l): void    {        $t = $this->terrain;        if ($l->frame % 3 !== 0) {            return;        }        if ($t->anySteel($l->x - 4, $l->y, 9, 1)) {            $this->events[] = ['clink', $l->x, $l->y, null];            $l->set(Lumi::WALK);            return;        }        $t->digRect($l->x - 4, $l->y - 2, 9, 3);        $l->y++;        $l->work++;        if ($l->work % 2 === 0) {            $this->events[] = ['dust', $l->x, $l->y - 1, null];        }        if (!$t->anySolid($l->x - 4, $l->y, 9, 2)) {            $this->startFall($l);        }    }}