Files
City10/app/Support/CityPlazas.php
T
marcocosta10000 090b530481 feat(city): add plaza exercise system with health tracking
- Adds CityPlazas support class for deterministic public plaza generation with outdoor gym equipment
- Implements exercise and stop-exercise actions with proximity validation to equipment
- Introduces health (0-100) and exercise_count attributes to Character model
- Reduces hospital stay duration from two game days to one game hour
- Adds 3D gym equipment rendering (bench, bars, bike, treadmill) in city viewer
- Includes exercise state machine handling in world ticker with energy drain
- Adds comprehensive PlazaExerciseTest and updates HospitalAdmissionTest
- Provides localized UI strings for health HUD and exercise prompts
2026-10-17 22:10:50 -03:00

143 lines
5.0 KiB
PHP
Executable File

<?php
namespace App\Support;
use App\Models\Building;
use App\Models\City;
use App\Models\Store;
/**
* Deterministic public plazas with outdoor gym equipment.
*
* Plazas replace free city blocks (not near stores, houses or the hospital).
* Equipment world positions must match the frontend generator so the server
* can validate exercise proximity.
*/
class CityPlazas
{
/**
* Equipment kinds rendered on each plaza.
*
* @var list<string>
*/
public const EQUIPMENT_KINDS = ['bench', 'bars', 'bike', 'treadmill'];
/**
* Build plaza + equipment layout for a city.
*
* @return list<array{id: string, position: array{0: float, 1: float}, size: array{0: float, 1: float}, equipment: list<array{id: string, kind: string, position: array{0: float, 1: float}}>}>
*/
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;
}
}