*/ public const EQUIPMENT_KINDS = ['bench', 'bars', 'bike', 'treadmill']; /** * Build plaza + equipment layout for a city. * * @return list}> */ public static function forCity(City $city): array { $minX = (int) floor(((float) $city->bounds_min_x) / 40) * 40 - 40; $maxX = (int) ceil(((float) $city->bounds_max_x) / 40) * 40 + 40; $minZ = (int) floor(((float) $city->bounds_min_z) / 40) * 40 - 40; $maxZ = (int) ceil(((float) $city->bounds_max_z) / 40) * 40 + 40; $occupied = Store::query() ->where(function ($q): void { // Stores are global for now; keep city filter ready for multi-city. $q->whereNotNull('id'); }) ->get(['id', 'pos_x', 'pos_z']) ->map(fn ($s): array => ['x' => (float) $s->pos_x, 'z' => (float) $s->pos_z]) ->all(); $occupied = array_merge($occupied, Building::query() ->where('city_id', $city->id) ->get(['id', 'pos_x', 'pos_z', 'type']) ->map(fn ($b): array => ['x' => (float) $b->pos_x, 'z' => (float) $b->pos_z]) ->all()); $plazas = []; $index = 0; for ($x = $minX; $x < $maxX; $x += 40) { for ($z = $minZ; $z < $maxZ; $z += 40) { $cx = $x + 20.0; $cz = $z + 20.0; $nearOccupied = array_reduce($occupied, function (bool $carry, array $o) use ($cx, $cz): bool { return $carry || (abs($o['x'] - $cx) < 25 && abs($o['z'] - $cz) < 25); }, false); // Keep a sparse, deterministic plaza pattern (~1 free block in 4). if ($nearOccupied || ($index % 4) !== 1) { $index++; continue; } $index++; $equipment = []; foreach (self::EQUIPMENT_KINDS as $k => $kind) { $angle = ($k / count(self::EQUIPMENT_KINDS)) * M_PI * 2; $ex = round($cx + cos($angle) * 8, 2); $ez = round($cz + sin($angle) * 8, 2); $equipment[] = [ 'id' => sprintf('plaza-%d-%s', count($plazas), $kind), 'kind' => $kind, 'position' => [$ex, $ez], ]; } $plazas[] = [ 'id' => 'plaza-'.count($plazas), 'position' => [$cx, $cz], 'size' => [22.0, 22.0], 'equipment' => $equipment, ]; } } // Guarantee at least one plaza near the city center for newcomers. if ($plazas === []) { $cx = 20.0; $cz = -20.0; $equipment = []; foreach (self::EQUIPMENT_KINDS as $k => $kind) { $angle = ($k / count(self::EQUIPMENT_KINDS)) * M_PI * 2; $equipment[] = [ 'id' => 'plaza-0-'.$kind, 'kind' => $kind, 'position' => [round($cx + cos($angle) * 8, 2), round($cz + sin($angle) * 8, 2)], ]; } $plazas[] = [ 'id' => 'plaza-0', 'position' => [$cx, $cz], 'size' => [22.0, 22.0], 'equipment' => $equipment, ]; } return $plazas; } /** * Find the nearest gym equipment within reach of a world position. * * @return array{plaza_id: string, kind: string, position: array{0: float, 1: float}, distance: float}|null */ public static function nearestEquipment(City $city, float $x, float $z, float $maxDistance = 3.0): ?array { $best = null; foreach (self::forCity($city) as $plaza) { foreach ($plaza['equipment'] as $eq) { $dx = $eq['position'][0] - $x; $dz = $eq['position'][1] - $z; $dist = hypot($dx, $dz); if ($dist <= $maxDistance && ($best === null || $dist < $best['distance'])) { $best = [ 'plaza_id' => $plaza['id'], 'kind' => $eq['kind'], 'position' => $eq['position'], 'distance' => $dist, ]; } } } return $best; } }