- 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
143 lines
5.0 KiB
PHP
Executable File
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;
|
|
}
|
|
}
|