feat(city): add east shore beach leisure activity

Characters can now relax on the beach sand to recover energy and
health. The server clamps position to the shoreline, exposes beach
geometry in the city payload, and the client renders animated waves,
sand, stalls, chairs, and ambient NPCs.

Includes Beach support class, new beach action endpoint, localization
strings, and feature tests.
This commit is contained in:
2026-10-31 20:35:22 -03:00
parent 4b71a9d0ff
commit b8c31e7be4
12 changed files with 827 additions and 5 deletions
@@ -9,6 +9,7 @@ use App\Models\City;
use App\Models\InventoryItem;
use App\Models\Job;
use App\Models\Transaction;
use App\Support\Beach;
use App\Support\CityPlazas;
use App\Support\MissionTracker;
use Illuminate\Http\JsonResponse;
@@ -48,7 +49,10 @@ class CityActionController extends Controller
}
$city = $character->city;
$x = max((float) $city->bounds_min_x, min((float) $city->bounds_max_x, (float) $data['pos_x']));
// Players may persist onto the east beach sand (past the road grid)
// but never beyond the shoreline.
$maxX = Beach::sandTo($city) + 2;
$x = max((float) $city->bounds_min_x, min($maxX, (float) $data['pos_x']));
$z = max((float) $city->bounds_min_z, min((float) $city->bounds_max_z, (float) $data['pos_z']));
$character->forceFill([
@@ -100,12 +104,63 @@ class CityActionController extends Controller
'build' => $this->build($request, $character),
'apply-job' => $this->applyJob($request, $character),
'exercise' => $this->exercise($request, $character),
'beach' => $this->beach($request, $character),
'stop-exercise' => $this->stopExercise($character),
'ride' => $this->ride($request, $character),
default => response()->json(['message' => __('city.action_unknown')], 422),
};
}
/**
* Relax on the east beach while standing on the sand.
*
* The client posts this on a slow timer during a relax session (the
* same pattern as exercise): each tick recovers a bit of energy and
* health, capped at 100, and only inside the beach zone.
*/
private function beach(Request $request, Character $character): JsonResponse
{
if ($character->state === Character::STATE_SLEEPING) {
return response()->json(['message' => __('city.exercise_need_awake')], 422);
}
if ($character->state === Character::STATE_EXERCISING) {
return response()->json(['message' => __('city.beach_busy')], 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;
if (! Beach::isOnBeach($character->city, $x, $z)) {
return response()->json(['message' => __('city.beach_need')], 422);
}
if ((int) $character->energy >= 100 && (int) $character->health >= 100) {
return response()->json([
'message' => __('city.beach_full'),
'full' => true,
'character' => $this->characterData($character),
]);
}
$character->forceFill([
'health' => min(100, (int) $character->health + 1),
'energy' => min(100, (int) $character->energy + 1),
'pos_x' => $x,
'pos_z' => $z,
])->save();
return response()->json([
'message' => __('city.beach_ok'),
'character' => $this->characterData($character),
]);
}
/**
* Board a nearby bus stop or train for a short ride.
*/
@@ -9,6 +9,7 @@ use App\Models\Character;
use App\Models\City;
use App\Models\Job;
use App\Models\Store;
use App\Support\Beach;
use App\Support\CityPlazas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -141,6 +142,7 @@ class CityViewerController extends Controller
] : null,
'layout' => $this->cityLayout($city, $stores->all()),
'plazas' => CityPlazas::forCity($city),
'beach' => Beach::geometry($city),
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
'id' => $a->id,
'name' => $a->name,