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:
@@ -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,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\City;
|
||||
|
||||
/**
|
||||
* East-shore beach geometry shared by the world payload, the leisure
|
||||
* action validation and the position clamp.
|
||||
*
|
||||
* Every value derives from the city bounds so the sand always starts just
|
||||
* past the eastern road grid, whatever the city size is.
|
||||
*/
|
||||
class Beach
|
||||
{
|
||||
/** Gap between the last grid road and the sand. */
|
||||
public const APPROACH = 4.0;
|
||||
|
||||
/** Sand depth from the approach line to the shoreline. */
|
||||
public const SAND_DEPTH = 60.0;
|
||||
|
||||
/** Sea depth beyond the shoreline. */
|
||||
public const SEA_DEPTH = 90.0;
|
||||
|
||||
/** Half of the beach span along Z (covers the whole layout). */
|
||||
public const Z_HALF = 160.0;
|
||||
|
||||
/**
|
||||
* Eastern edge of the road grid (same derivation the layout uses).
|
||||
*/
|
||||
public static function layoutMaxX(City $city): float
|
||||
{
|
||||
return (float) (ceil(((float) $city->bounds_max_x) / 40) * 40 + 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the sand begins (east of the grid).
|
||||
*/
|
||||
public static function sandFrom(City $city): float
|
||||
{
|
||||
return self::layoutMaxX($city) + self::APPROACH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shoreline (sand meets sea).
|
||||
*/
|
||||
public static function sandTo(City $city): float
|
||||
{
|
||||
return self::sandFrom($city) + self::SAND_DEPTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Far edge of the sea.
|
||||
*/
|
||||
public static function seaTo(City $city): float
|
||||
{
|
||||
return self::sandTo($city) + self::SEA_DEPTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry descriptor sent to the world payload (single source of
|
||||
* truth for the client-side beach build).
|
||||
*
|
||||
* @return array{sand_from: float, sand_to: float, sea_to: float, z_min: float, z_max: float}
|
||||
*/
|
||||
public static function geometry(City $city): array
|
||||
{
|
||||
return [
|
||||
'sand_from' => self::sandFrom($city),
|
||||
'sand_to' => self::sandTo($city),
|
||||
'sea_to' => self::seaTo($city),
|
||||
'z_min' => -self::Z_HALF,
|
||||
'z_max' => self::Z_HALF,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a point lies on the leisure beach (sand up to the shoreline).
|
||||
*/
|
||||
public static function isOnBeach(City $city, float $x, float $z): bool
|
||||
{
|
||||
return $x >= self::sandFrom($city) - 1
|
||||
&& $x <= self::sandTo($city) + 2
|
||||
&& $z >= -self::Z_HALF
|
||||
&& $z <= self::Z_HALF;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user