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:
@@ -134,6 +134,13 @@ class CityTick extends Command
|
||||
$character->energy = max(0, (int) $character->energy - (int) round(0.8 * $elapsedMin));
|
||||
break;
|
||||
|
||||
case Character::STATE_EXERCISING:
|
||||
$character->energy = max(0, (int) $character->energy - (int) round(1.0 * $elapsedMin));
|
||||
if ((int) $character->energy <= 5) {
|
||||
$character->state = Character::STATE_AWAKE;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$drain = $starving ? 0.8 : 0.4;
|
||||
$character->energy = max(0, (int) $character->energy - (int) round($drain * $elapsedMin));
|
||||
@@ -162,6 +169,13 @@ class CityTick extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
// Offline exercise sessions end after the next tick so NPCs recover.
|
||||
if ($character->state === Character::STATE_EXERCISING) {
|
||||
$character->state = Character::STATE_AWAKE;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($character->energy < 20 && $character->house_id !== null) {
|
||||
$character->state = Character::STATE_SLEEPING;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Character;
|
||||
use App\Models\City;
|
||||
use App\Models\Job;
|
||||
use App\Models\Store;
|
||||
use App\Support\CityPlazas;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -128,7 +129,9 @@ class CityViewerController extends Controller
|
||||
'energy' => (int) $me->energy,
|
||||
'hunger' => (int) $me->hunger,
|
||||
'thirst' => (int) $me->thirst,
|
||||
'health' => (int) $me->health,
|
||||
'education' => (int) $me->education,
|
||||
'exercise_count' => (int) $me->exercise_count,
|
||||
'job_id' => $me->job_id,
|
||||
'job_title' => $me->job?->title,
|
||||
'house_id' => $me->house_id,
|
||||
@@ -137,6 +140,7 @@ class CityViewerController extends Controller
|
||||
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||
] : null,
|
||||
'layout' => $this->cityLayout($city, $stores->all()),
|
||||
'plazas' => CityPlazas::forCity($city),
|
||||
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
|
||||
@@ -17,8 +17,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
*/
|
||||
#[Fillable([
|
||||
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
|
||||
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst',
|
||||
'state', 'job_id', 'house_id', 'education', 'is_online', 'last_tick_at',
|
||||
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
|
||||
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
|
||||
'hospitalized_until',
|
||||
])]
|
||||
class Character extends Model
|
||||
@@ -34,6 +34,13 @@ class Character extends Model
|
||||
|
||||
public const STATE_STUDYING = 'studying';
|
||||
|
||||
/**
|
||||
* Character is exercising on public plaza gym equipment.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const STATE_EXERCISING = 'exercising';
|
||||
|
||||
/**
|
||||
* Character admitted to the hospital after a traffic accident.
|
||||
*
|
||||
@@ -42,11 +49,14 @@ class Character extends Model
|
||||
public const STATE_HOSPITAL = 'hospital';
|
||||
|
||||
/**
|
||||
* Hospital stay length in seconds (two game days of 240s each).
|
||||
* Hospital stay length in seconds (one game hour).
|
||||
*
|
||||
* The day/night cycle is 240 real seconds per 24 game hours, so one
|
||||
* game hour equals 10 real seconds.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const HOSPITAL_STAY_SECONDS = 480;
|
||||
public const HOSPITAL_STAY_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* Default hospital bed coordinates used when no hospital building exists.
|
||||
@@ -153,8 +163,10 @@ class Character extends Model
|
||||
'energy' => 100,
|
||||
'hunger' => 100,
|
||||
'thirst' => 100,
|
||||
'health' => 100,
|
||||
'state' => self::STATE_AWAKE,
|
||||
'education' => 0,
|
||||
'exercise_count' => 0,
|
||||
'is_online' => false,
|
||||
]);
|
||||
|
||||
@@ -189,7 +201,9 @@ class Character extends Model
|
||||
'energy' => 'integer',
|
||||
'hunger' => 'integer',
|
||||
'thirst' => 'integer',
|
||||
'health' => 'integer',
|
||||
'education' => 'integer',
|
||||
'exercise_count' => 'integer',
|
||||
'is_online' => 'boolean',
|
||||
'last_tick_at' => 'datetime',
|
||||
'hospitalized_until' => 'datetime',
|
||||
@@ -207,7 +221,7 @@ class Character extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit the character to the hospital for two game days after an accident.
|
||||
* Admit the character to the hospital for one game hour after an accident.
|
||||
*
|
||||
* Teleports them to the city hospital bed and returns the bed position.
|
||||
*
|
||||
@@ -236,7 +250,7 @@ class Character extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the character from the hospital when the stay has elapsed.
|
||||
* Release the character from the hospital when the one-hour stay has elapsed.
|
||||
*/
|
||||
public function releaseIfHospitalStayOver(): bool
|
||||
{
|
||||
|
||||
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