<?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); } }}