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
This commit is contained in:
Executable
+142
@@ -0,0 +1,142 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user