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:
2026-10-17 22:10:50 -03:00
parent 79ce2a1825
commit 090b530481
18 changed files with 848 additions and 21 deletions
@@ -9,6 +9,7 @@ use App\Models\City;
use App\Models\InventoryItem;
use App\Models\Job;
use App\Models\Transaction;
use App\Support\CityPlazas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -97,14 +98,90 @@ class CityActionController extends Controller
'sell' => $this->sell($request, $character),
'build' => $this->build($request, $character),
'apply-job' => $this->applyJob($request, $character),
'exercise' => $this->exercise($request, $character),
'stop-exercise' => $this->stopExercise($character),
default => response()->json(['message' => __('city.action_unknown')], 422),
};
}
/**
* One exercise rep on nearby plaza gym equipment: +health, −energy.
*
* Optional pos_x/pos_z let the client report its live position so the
* proximity check does not wait for the throttled position sync.
*/
private function exercise(Request $request, Character $character): JsonResponse
{
if ($character->state === Character::STATE_SLEEPING) {
return response()->json(['message' => __('city.exercise_need_awake')], 422);
}
$data = $request->validate([
'pos_x' => ['nullable', 'numeric'],
'pos_z' => ['nullable', 'numeric'],
]);
$x = array_key_exists('pos_x', $data) ? (float) $data['pos_x'] : (float) $character->pos_x;
$z = array_key_exists('pos_z', $data) ? (float) $data['pos_z'] : (float) $character->pos_z;
$nearby = CityPlazas::nearestEquipment(
$character->city,
$x,
$z,
4.0,
);
if ($nearby === null) {
return response()->json(['message' => __('city.exercise_need_equipment')], 422);
}
if ((int) $character->energy < 5) {
return response()->json(['message' => __('city.exercise_tired')], 422);
}
if ((int) $character->health >= 100) {
return response()->json([
'message' => __('city.exercise_max_health'),
'character' => $this->characterData($character),
]);
}
$character->forceFill([
'state' => Character::STATE_EXERCISING,
'health' => min(100, (int) $character->health + 2),
'energy' => max(0, (int) $character->energy - 2),
'exercise_count' => (int) $character->exercise_count + 1,
'pos_x' => $x,
'pos_z' => $z,
])->save();
return response()->json([
'message' => __('city.exercise_ok'),
'equipment' => $nearby,
'character' => $this->characterData($character),
]);
}
/**
* Leave the exercising state (back to awake).
*/
private function stopExercise(Character $character): JsonResponse
{
if ($character->state === Character::STATE_EXERCISING) {
$character->state = Character::STATE_AWAKE;
$character->save();
}
return response()->json([
'message' => __('city.action_ok'),
'character' => $this->characterData($character),
]);
}
/**
* Admit the character to the hospital after a vehicle collision.
*
* Stays two game days (Character::HOSPITAL_STAY_SECONDS).
* Stays one game hour (Character::HOSPITAL_STAY_SECONDS).
*/
private function accident(Character $character): JsonResponse
{
@@ -483,7 +560,9 @@ class CityActionController extends Controller
'energy' => (int) $character->energy,
'hunger' => (int) $character->hunger,
'thirst' => (int) $character->thirst,
'health' => (int) $character->health,
'education' => (int) $character->education,
'exercise_count' => (int) $character->exercise_count,
'job_id' => $character->job_id,
'job_title' => $character->job?->title,
'house_id' => $character->house_id,